Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions frontend/components/playbooks/PlaybookEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
useState,
} from "react";
import { useSearchParams } from "next/navigation";
import { api, ApiError, type Capabilities, type SyncState, type ToolEntry } from "@/lib/api";
import { api, ApiError, type Capabilities, type LinkedRepo, type SyncState, type ToolEntry } from "@/lib/api";
import { expandRepoAliases } from "@/lib/mcps";
import { useDialog } from "@/lib/dialog";
import { EditorChatDrawer } from "@/components/playbooks/EditorChatDrawer";
import { ProposalCard, type ProposalDraftPayload } from "@/components/playbooks/ProposalCard";
Expand Down Expand Up @@ -83,6 +84,11 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props)
// "latest" instead of the freshly-created commit.
const [refetchKey, setRefetchKey] = useState(0);
const [tools, setTools] = useState<ToolEntry[]>([]);
const [linkedRepos, setLinkedRepos] = useState<LinkedRepo[]>([]);
// The picker resolves suggested_calls against per-repo server names
// (triagent-git-<alias>), so the logical catalog is expanded with
// one entry per linked repo before it reaches the node editor.
const catalog = useMemo(() => expandRepoAliases(tools, linkedRepos), [tools, linkedRepos]);
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
// Right-aside view: "node" is the per-node editor; "tags" surfaces
Expand Down Expand Up @@ -555,6 +561,10 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props)
// on capabilities.gh.authenticated && capabilities.repoPath.valid.
useEffect(() => {
api.listTools().then(setTools).catch(() => setTools([]));
api
.listRepos()
.then((r) => setLinkedRepos([...r.defaults, ...r.user]))
.catch(() => setLinkedRepos([]));
api
.listPlaybookTypes()
.then((types) =>
Expand Down Expand Up @@ -1390,7 +1400,7 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props)
node={node}
allNodeIds={allNodeIds}
allPlaybookIds={allPlaybookIds.filter((p) => p !== draft.id)}
catalog={tools}
catalog={catalog}
readOnly={readOnly}
onChange={(next) => {
if (!selectedNode) return;
Expand Down
2 changes: 1 addition & 1 deletion frontend/components/playbooks/ToolPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ export function ToolPicker({ value, onChange, catalog, disabled }: Props) {
<span className="flex items-center gap-1.5">
{value && !known && (
<span
title="Tool not in current catalog — typo, renamed, or from a newer triagent-mcp version"
title="Tool not in current catalog — typo, renamed, a repo that isn't linked, or from a newer triagent-mcp version"
className="rounded bg-amber-900/60 px-1 text-xs text-amber-200"
>
unknown
Expand Down
47 changes: 45 additions & 2 deletions frontend/lib/mcps.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from "vitest";
import type { Investigation } from "@/lib/api";
import { activeMCPs, chipClasses } from "@/lib/mcps";
import type { Investigation, LinkedRepo, ToolEntry } from "@/lib/api";
import { activeMCPs, chipClasses, expandRepoAliases } from "@/lib/mcps";

function makeInv(overrides: Partial<Investigation> = {}): Investigation {
return {
Expand Down Expand Up @@ -47,3 +47,46 @@ describe("activeMCPs cloud sources", () => {
expect(chipClasses("cloud")).not.toBe(chipClasses("docs"));
});
});

describe("expandRepoAliases", () => {
const gitTool: ToolEntry = {
server: "triagent-git",
name: "latest_tags",
description: "List tags",
inputs: [{ name: "limit", type: "integer" }],
};
const k8sTool: ToolEntry = { server: "triagent-k8s", name: "get_logs", description: "Logs" };

it("adds one copy of every git tool per linked repo alias, keeping the logical entry", () => {
const out = expandRepoAliases(
[k8sTool, gitTool],
[
{ owner: "camunda", name: "camunda-operator" },
{ owner: "camunda", name: "saas-argocd-apps", alias: "argocd" },
],
);
expect(out.map((t) => `${t.server}/${t.name}`)).toEqual([
"triagent-k8s/get_logs",
"triagent-git/latest_tags",
"triagent-git-camunda-operator/latest_tags",
"triagent-git-argocd/latest_tags",
]);
// The aliased copy carries the same inputs so the editor can offer args.
expect(out[2].inputs).toEqual(gitTool.inputs);
});

it("dedupes repos that resolve to the same alias", () => {
const out = expandRepoAliases(
[gitTool],
[
{ owner: "camunda", name: "zeebe" },
{ owner: "fork", name: "zeebe" },
],
);
expect(out.map((t) => t.server)).toEqual(["triagent-git", "triagent-git-zeebe"]);
});

it("returns the catalog untouched when no repos are linked", () => {
expect(expandRepoAliases([k8sTool, gitTool], [])).toEqual([k8sTool, gitTool]);
});
});
22 changes: 21 additions & 1 deletion frontend/lib/mcps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// already on the Investigation DTO (promEnabled / docsPrefix / linkedRepos);
// this module just turns it into a renderable list and sorts the entries.

import type { Investigation, MCPCallStats, MCPProbeResult, ToolEntry } from "./api";
import type { Investigation, LinkedRepo, MCPCallStats, MCPProbeResult, ToolEntry } from "./api";

export type MCPCategory = "core" | "metrics" | "docs" | "git" | "wiki" | "slack" | "incidentio" | "cloud";

Expand Down Expand Up @@ -137,6 +137,26 @@ export function logicalServer(wireAlias: string): string {
return wireAlias;
}

// expandRepoAliases turns the launcher's logical git tool set into the
// per-repo entries a playbook actually addresses. The catalog lists each
// git tool once under "triagent-git", but every linked repo runs its own
// server named triagent-git-<alias>, and that aliased form is what
// suggested_calls carry. The logical entry stays so system playbooks
// written against the template form still resolve; one copy per alias
// follows in repo order, deduped when two repos resolve to one alias.
export function expandRepoAliases(tools: ToolEntry[], repos: LinkedRepo[]): ToolEntry[] {
const aliases = new Set(repos.map((r) => r.alias || r.name));
if (aliases.size === 0) return tools;
const gitTools = tools.filter((t) => t.server === "triagent-git");
const out = [...tools];
for (const alias of aliases) {
for (const t of gitTools) {
out.push({ ...t, server: `triagent-git-${alias}` });
}
}
return out;
}

// groupTools buckets the flat tool list by server alias. Returns a Map
// so callers can iterate insertion-order-preserving.
export function groupTools(tools: ToolEntry[]): Map<string, ToolEntry[]> {
Expand Down
Loading