diff --git a/cmd/triagent-mcp/serve.go b/cmd/triagent-mcp/serve.go index 29d3f6ab..b00057e1 100644 --- a/cmd/triagent-mcp/serve.go +++ b/cmd/triagent-mcp/serve.go @@ -52,6 +52,9 @@ const ( envSessionsProposalsPath = "TRIAGENT_MCP_SESSIONS_PROPOSALS_PATH" envSessionsClaudeBinary = "TRIAGENT_MCP_SESSIONS_CLAUDE_BINARY" + envTeleportProxy = "TRIAGENT_MCP_TELEPORT_PROXY" + envTeleportAuthConnector = "TRIAGENT_MCP_TELEPORT_AUTH_CONNECTOR" + envSlackToken = "TRIAGENT_MCP_SLACK_TOKEN" envIncidentioToken = "TRIAGENT_MCP_INCIDENTIO_TOKEN" @@ -69,6 +72,10 @@ type serveFlags struct { crdsFile string crossplaneGroups string + // teleport flags + teleportProxy string + teleportAuthConnector string + // strategies flags sessionDir string userPlaybooksDir string @@ -126,6 +133,10 @@ func serveCmd() *cobra.Command { cmd.Flags().StringVar(&f.crdsFile, "crds-file", "", "JSON file overriding the embedded resource allow-list (defaults to $"+envCRDsFile+") [kind=k8s]") cmd.Flags().StringVar(&f.crossplaneGroups, "crossplane-groups", "", "comma-separated glob patterns for Crossplane provider API groups (defaults to $"+envCrossplaneGroups+", then '*.upbound.io,*.crossplane.io') [kind=k8s]") + // teleport flags + cmd.Flags().StringVar(&f.teleportProxy, "teleport-proxy", "", "Teleport proxy address for tsh login (defaults to $"+envTeleportProxy+") [kind=teleport]") + cmd.Flags().StringVar(&f.teleportAuthConnector, "teleport-auth-connector", "", "Teleport SSO connector for tsh login (defaults to $"+envTeleportAuthConnector+") [kind=teleport]") + // strategies flags cmd.Flags().StringVar(&f.sessionDir, "session-dir", "", "directory the strategies walker uses to snapshot state; also required for k8s streaming tools (defaults to $"+envSessionDir+") [kind=strategies,k8s]") cmd.Flags().StringVar(&f.userPlaybooksDir, "user-playbooks-dir", "", "directory holding operator-customised playbooks layered over the plugin set (defaults to $"+envUserPlaybooksDir+") [kind=strategies]") @@ -175,6 +186,12 @@ func resolveFlags(f *serveFlags) serveFlags { if out.crossplaneGroups == "" { out.crossplaneGroups = os.Getenv(envCrossplaneGroups) } + if out.teleportProxy == "" { + out.teleportProxy = os.Getenv(envTeleportProxy) + } + if out.teleportAuthConnector == "" { + out.teleportAuthConnector = os.Getenv(envTeleportAuthConnector) + } if out.sessionDir == "" { out.sessionDir = os.Getenv(envSessionDir) } @@ -306,11 +323,15 @@ func runK8s(ctx context.Context, f serveFlags) error { func runTeleport(ctx context.Context, f serveFlags) error { kubePath := resolveKubeconfigPath(f.kubeconfig) - srv, err := teleport.New(teleport.Options{KubeconfigPath: kubePath}) + srv, err := teleport.New(teleport.Options{ + KubeconfigPath: kubePath, + Proxy: f.teleportProxy, + AuthConnector: f.teleportAuthConnector, + }) if err != nil { return fmt.Errorf("build teleport mcp server: %w", err) } - log.Info("mcp serve --kind=teleport starting", "kubeconfig", kubePath) + log.Info("mcp serve --kind=teleport starting", "kubeconfig", kubePath, "proxy", f.teleportProxy) return srv.Run(ctx) } diff --git a/cmd/triagent-mcp/serve_test.go b/cmd/triagent-mcp/serve_test.go index ce20af2b..69b70716 100644 --- a/cmd/triagent-mcp/serve_test.go +++ b/cmd/triagent-mcp/serve_test.go @@ -3,6 +3,7 @@ package main import ( "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -11,3 +12,17 @@ func TestServeCmd_KnowsAgentOperatorKind(t *testing.T) { require.NotNil(t, cmd) require.Contains(t, cmd.Long, "agent-operator") } + +func TestResolveFlags_ReadsTeleportProxyAndConnectorFromEnv(t *testing.T) { + t.Setenv("TRIAGENT_MCP_TELEPORT_PROXY", "proxy.example.com") + t.Setenv("TRIAGENT_MCP_TELEPORT_AUTH_CONNECTOR", "github") + got := resolveFlags(&serveFlags{}) + assert.Equal(t, "proxy.example.com", got.teleportProxy) + assert.Equal(t, "github", got.teleportAuthConnector) +} + +func TestResolveFlags_ExplicitTeleportFlagsWinOverEnv(t *testing.T) { + t.Setenv("TRIAGENT_MCP_TELEPORT_PROXY", "from-env") + got := resolveFlags(&serveFlags{teleportProxy: "from-flag"}) + assert.Equal(t, "from-flag", got.teleportProxy) +} diff --git a/docs/images/playbook-catalog.png b/docs/images/playbook-catalog.png index 8aef4989..59a235e0 100644 Binary files a/docs/images/playbook-catalog.png and b/docs/images/playbook-catalog.png differ diff --git a/docs/images/playbook-editor.png b/docs/images/playbook-editor.png index 0cd06563..f0cec674 100644 Binary files a/docs/images/playbook-editor.png and b/docs/images/playbook-editor.png differ diff --git a/docs/images/repositories.png b/docs/images/repositories.png index 511f180b..429494b9 100644 Binary files a/docs/images/repositories.png and b/docs/images/repositories.png differ diff --git a/docs/images/session-screenshot.png b/docs/images/session-screenshot.png index b0d66d6e..9328c7a1 100644 Binary files a/docs/images/session-screenshot.png and b/docs/images/session-screenshot.png differ diff --git a/docs/images/watches-screenshot.png b/docs/images/watches-screenshot.png index d526f2be..f6e3d569 100644 Binary files a/docs/images/watches-screenshot.png and b/docs/images/watches-screenshot.png differ diff --git a/docs/images/wiki-catalog.png b/docs/images/wiki-catalog.png index a23739af..621e4191 100644 Binary files a/docs/images/wiki-catalog.png and b/docs/images/wiki-catalog.png differ diff --git a/docs/images/wiki-editor.png b/docs/images/wiki-editor.png index 2858fb1d..6982c538 100644 Binary files a/docs/images/wiki-editor.png and b/docs/images/wiki-editor.png differ diff --git a/e2e/browser/specs/nested-proposals.spec.ts b/e2e/browser/specs/nested-proposals.spec.ts new file mode 100644 index 00000000..42ed4a64 --- /dev/null +++ b/e2e/browser/specs/nested-proposals.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; +import { + proposalCardOfKind, + waitForAssistantText, + waitForProposalCards, +} from "../helpers/triagent"; +import { + clickNewInvestigation, + fillAndSubmitInvestigationForm, + gotoRoot, +} from "../helpers/walkthrough"; + +// Proposals drafted inside a walk_playbook sub-agent dispatch arrive nested +// (their tool-events carry parentToolId). The transcript folder hoists the +// wiki + playbook proposal results out of that nesting so their inline +// approve/decline cards still render in the session view — the regression +// this pins. The nested codefix card is NOT hoisted (codefix surfaces on the +// repos activity panel instead, pinned in TestProposalSurfacing_NestedBackendInvariants), +// so the transcript shows exactly the two hoisted cards. +test.describe("nested sub-agent proposal surfacing", () => { + test("hoists wiki + playbook cards out of the dispatch nesting", async ({ + page, + }) => { + await gotoRoot(page); + await clickNewInvestigation(page); + await fillAndSubmitInvestigationForm(page, { + Notes: "nested-proposal surfacing check", + }); + + // Preflight mounts the session view (composer present) and SessionWorkspace + // auto-starts the kickoff turn that stages the nested proposals. + await expect(page.getByTestId("triagent-composer-input")).toBeVisible({ + timeout: 30_000, + }); + await waitForAssistantText(page, "guided proposal sub-agents"); + + // Exactly two inline cards — wiki and playbook — even though both were + // drafted inside the dispatch sub-agent. The sub-agent also made a + // validation-failed playbook draft (proposal_id:""); that must NOT render + // as an empty card, so the playbook card count is exactly one. + await waitForProposalCards(page, 2); + await expect(proposalCardOfKind(page, "wiki")).toBeVisible(); + await expect(proposalCardOfKind(page, "playbook")).toHaveCount(1); + }); +}); diff --git a/e2e/browser/specs/playbook-proposal-view.spec.ts b/e2e/browser/specs/playbook-proposal-view.spec.ts new file mode 100644 index 00000000..76445088 --- /dev/null +++ b/e2e/browser/specs/playbook-proposal-view.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; +import { + openPlaybooks, + openProposalPreview, + playbookProposal, +} from "../helpers/editor"; + +// A pending proposal for a BRAND-NEW playbook (no live playbook with that id +// on disk) must open from the sidenav. The editor deep-links via +// ?playbook=&proposal=&tab=proposal; getPlaybook() 404s because +// nothing's been promoted yet, so the editor has to fall back to seeding from +// the proposal draft instead of showing "playbook not found". +test.describe("new-playbook proposal view", () => { + test("opens a brand-new playbook proposal from the sidenav", async ({ + page, + }) => { + await openPlaybooks(page); + + // The sidenav lists the pending proposal even though no live playbook + // exists for its id. + await expect(playbookProposal(page, "new_synthetic_playbook")).toBeVisible({ + timeout: 30_000, + }); + + // Clicking it must open the proposal preview — openProposalPreview asserts + // the Approve button renders, which only happens if the editor seeded from + // the proposal draft rather than 404-ing on the missing base playbook. + await openProposalPreview(page, "new_synthetic_playbook"); + }); +}); diff --git a/e2e/browser/specs/wiki-proposal-view.spec.ts b/e2e/browser/specs/wiki-proposal-view.spec.ts new file mode 100644 index 00000000..eeb78bf5 --- /dev/null +++ b/e2e/browser/specs/wiki-proposal-view.spec.ts @@ -0,0 +1,38 @@ +import { expect, test } from "@playwright/test"; +import { gotoAuthed } from "../helpers/triagent"; +import { editorTestids } from "../helpers/editor"; + +// A pending proposal for a BRAND-NEW wiki entry (no entry with that slug on +// disk) must open from its sidenav deep-link. The link the sidebar produces is +// /wiki/entries/?slug=&proposal=&tab=proposal. The wiki backend +// returns a synthetic is_stub entry instead of 404, so the editor mounts and +// hydrates the proposal into its AI-proposal tab — the Approve button renders. +const slug = process.env.TRIAGENT_WIKI_SLUG ?? ""; +const proposalID = process.env.TRIAGENT_WIKI_PROPOSAL_ID ?? ""; + +test.describe("new-wiki-entry proposal view", () => { + test("opens a brand-new wiki entry proposal from its deep-link", async ({ + page, + }) => { + expect(slug, "TRIAGENT_WIKI_SLUG must be set").not.toBe(""); + expect(proposalID, "TRIAGENT_WIKI_PROPOSAL_ID must be set").not.toBe(""); + + await gotoAuthed( + page, + `/wiki/entries/?slug=${encodeURIComponent(slug)}&proposal=${encodeURIComponent( + proposalID, + )}&tab=proposal`, + ); + + // The editor mounted (no "entry not found") and the proposal hydrated into + // its AI-proposal tab — the proposed content is viewable. + await expect(page.getByTestId(editorTestids.wikiEditor)).toBeVisible({ + timeout: 30_000, + }); + await expect( + page.getByText("A brand-new wiki entry proposed by the agent", { + exact: false, + }), + ).toBeVisible({ timeout: 30_000 }); + }); +}); diff --git a/e2e/cmd/claude-stub/proposal.go b/e2e/cmd/claude-stub/proposal.go index ab8d6e5a..4199bd06 100644 --- a/e2e/cmd/claude-stub/proposal.go +++ b/e2e/cmd/claude-stub/proposal.go @@ -32,12 +32,13 @@ type poster struct { // (internal/server/handlers.go). Only the fields the stub sets are // modelled; the launcher tolerates the omitted ones. type toolEventBody struct { - Phase string `json:"phase"` - TraceID string `json:"traceId"` - ToolID string `json:"toolId"` - ToolName string `json:"toolName,omitempty"` - Input json.RawMessage `json:"input,omitempty"` - Result string `json:"result,omitempty"` + Phase string `json:"phase"` + TraceID string `json:"traceId"` + ToolID string `json:"toolId"` + ToolName string `json:"toolName,omitempty"` + ParentToolID string `json:"parentToolId,omitempty"` + Input json.RawMessage `json:"input,omitempty"` + Result string `json:"result,omitempty"` } // mcpConfigEnvSnippet is the minimal view of a launcher-written mcp.json @@ -96,23 +97,28 @@ func (p *poster) nextToolID() string { // render the proposal card. The end event also carries toolName because // the launcher branches on it to persist draft-PR / GitHub-issue codefix // proposals (handleToolEvent); a real triagent-mcp end event sets it too. -func (p *poster) roundTrip(toolName string, input json.RawMessage, result string) (string, error) { - id := p.nextToolID() +func (p *poster) roundTrip(toolName string, input json.RawMessage, result, toolID, parentToolID string) (string, error) { + id := toolID + if id == "" { + id = p.nextToolID() + } if err := p.post(toolEventBody{ - Phase: "start", - TraceID: p.traceID, - ToolID: id, - ToolName: toolName, - Input: input, + Phase: "start", + TraceID: p.traceID, + ToolID: id, + ToolName: toolName, + ParentToolID: parentToolID, + Input: input, }); err != nil { return id, err } if err := p.post(toolEventBody{ - Phase: "end", - TraceID: p.traceID, - ToolID: id, - ToolName: toolName, - Result: result, + Phase: "end", + TraceID: p.traceID, + ToolID: id, + ToolName: toolName, + ParentToolID: parentToolID, + Result: result, }); err != nil { return id, err } diff --git a/e2e/cmd/claude-stub/proposal_test.go b/e2e/cmd/claude-stub/proposal_test.go index 792d8e2a..61b7fd20 100644 --- a/e2e/cmd/claude-stub/proposal_test.go +++ b/e2e/cmd/claude-stub/proposal_test.go @@ -86,6 +86,60 @@ func TestReplay_ProposalPostsStartAndEndToolEvents(t *testing.T) { } } +// A proposal action with an explicit toolId + parentToolId models a +// proposal drafted INSIDE a walk_playbook sub-agent: its tool-events nest +// under the dispatch. The stub must forward parentToolId verbatim so the +// launcher publishes a nested envelope — the exact shape the surfacing +// fixes (card hoist, global event, codefix persist) have to handle. +func TestReplay_NestedProposalCarriesParentToolID(t *testing.T) { + var mu sync.Mutex + var got []toolEventBody + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var b toolEventBody + if err := json.NewDecoder(r.Body).Decode(&b); err != nil { + t.Errorf("decode body: %v", err) + } + mu.Lock() + got = append(got, b) + mu.Unlock() + w.WriteHeader(http.StatusAccepted) + })) + defer srv.Close() + + p := &poster{client: srv.Client(), url: srv.URL + "/api/internal/tool-events", traceID: "inv-1"} + + actions := []action{ + {Action: "proposal", Name: "mcp__triagent-strategies__walk_playbook", ToolID: "dispatch-1", + Result: json.RawMessage(`{"dispatched":{"summary":"running"}}`)}, + {Action: "proposal", Name: "mcp__triagent-wiki__propose_wiki_draft", ToolID: "wiki-nested", ParentToolID: "dispatch-1", + Result: json.RawMessage(`{"kind":"wiki_proposal_draft","proposal_id":"pw-1"}`)}, + {Action: "exit", Code: 0}, + } + + tr := &trace{f: os.NewFile(0, ""), enc: json.NewEncoder(&bytes.Buffer{})} + out := bufio.NewWriter(&bytes.Buffer{}) + if _, err := replay(actions, bufio.NewReader(strings.NewReader("")), out, tr, replayDeps{poster: p}); err != nil { + t.Fatalf("replay: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(got) != 4 { + t.Fatalf("got %d tool-events, want 4 (2 per proposal): %+v", len(got), got) + } + for _, ev := range got[2:] { // the wiki child's start+end + if ev.ToolID != "wiki-nested" { + t.Errorf("nested proposal toolId = %q, want the explicit wiki-nested", ev.ToolID) + } + if ev.ParentToolID != "dispatch-1" { + t.Errorf("nested proposal parentToolId = %q, want dispatch-1 (must nest under the dispatch)", ev.ParentToolID) + } + } + if got[0].ParentToolID != "" { + t.Errorf("the dispatch itself must be top-level, got parentToolId %q", got[0].ParentToolID) + } +} + // A nil poster means telemetry isn't configured (the launcher ran the // stub without an MCP-config telemetry block, e.g. the stub's own unit // tests). A proposal action must then degrade to emitting the stream diff --git a/e2e/cmd/claude-stub/script.go b/e2e/cmd/claude-stub/script.go index 65b1cdfa..b0c20826 100644 --- a/e2e/cmd/claude-stub/script.go +++ b/e2e/cmd/claude-stub/script.go @@ -40,6 +40,12 @@ type action struct { Input json.RawMessage `json:"input,omitempty"` Result json.RawMessage `json:"result,omitempty"` Gh []string `json:"gh,omitempty"` + // ToolID, when set, overrides the auto-minted tool id so a later + // proposal can reference it as ParentToolID. ParentToolID nests this + // proposal's tool-events under that parent — modelling a proposal + // drafted inside a walk_playbook sub-agent dispatch. + ToolID string `json:"toolId,omitempty"` + ParentToolID string `json:"parentToolId,omitempty"` } // scriptEvent is the simplified event shape carried by an "emit" action. @@ -254,7 +260,7 @@ func emitProposal(out *bufio.Writer, tr *trace, p *poster, a action) error { } toolID := "" if p != nil { - id, err := p.roundTrip(a.Name, a.Input, result) + id, err := p.roundTrip(a.Name, a.Input, result, a.ToolID, a.ParentToolID) if err != nil { return err } diff --git a/e2e/fixtures/playbooks/with-new-playbook-proposal/proposals/investigation/new_synthetic_playbook__synthpid01.yaml b/e2e/fixtures/playbooks/with-new-playbook-proposal/proposals/investigation/new_synthetic_playbook__synthpid01.yaml new file mode 100644 index 00000000..5bcfac17 --- /dev/null +++ b/e2e/fixtures/playbooks/with-new-playbook-proposal/proposals/investigation/new_synthetic_playbook__synthpid01.yaml @@ -0,0 +1,13 @@ +id: new_synthetic_playbook +schema_version: 1 +type: investigation +symptom: "Synthetic new-playbook proposal — no live playbook with this id exists on disk" +description: | + A brand-new playbook proposed by the agent. There is no live playbook + with this id, so opening the proposal must seed the editor from the + draft rather than 404 on getPlaybook. +entrypoint: assess +nodes: + assess: + description: "Assess whether the pattern applies, then report the verdict." + terminal_advice: "Report the assessment and stand down." diff --git a/e2e/fixtures/stub-scripts/nested-proposals/main.jsonl b/e2e/fixtures/stub-scripts/nested-proposals/main.jsonl new file mode 100644 index 00000000..98b7add6 --- /dev/null +++ b/e2e/fixtures/stub-scripts/nested-proposals/main.jsonl @@ -0,0 +1,18 @@ +// Models a turn where the proposals are drafted INSIDE a walk_playbook +// sub-agent dispatch: the three proposal tool-events nest under the +// dispatch (parentToolId=dispatch-nested), the exact shape that kept +// sub-agent-drafted proposals from surfacing. The launcher's +// nesting-independent surfacing must still fire: codefix persists from the +// draft_pr end-event, and the wiki end-event fans a wiki_proposal_created +// global event. The inline cards (wiki + playbook) are hoisted out of the +// nesting on the frontend — asserted in the browser layer. +{"action":"record_args"} +{"action":"emit","event":{"type":"assistant_message","text":"Drafting follow-ups through the guided proposal sub-agents."}} +{"action":"proposal","name":"mcp__triagent-strategies__summarize","input":{"playbook_id":"investigation"},"result":{"markdown":"## Summary\n\nNested-proposal e2e scenario.","evidence_markdown":"- the proposals are drafted inside a dispatch sub-agent"}} +{"action":"proposal","name":"mcp__triagent-strategies__walk_playbook","toolId":"dispatch-nested","input":{"playbook_id":"wiki_proposal"},"result":{"session_id":"","dispatched":{"summary":"ran the proposal sub-agents"}}} +{"action":"proposal","name":"mcp__triagent-wiki__propose_wiki_draft","toolId":"wiki-nested","parentToolId":"dispatch-nested","input":{"slug":"nested-wiki"},"result":{"kind":"wiki_proposal_draft","proposal_id":"prop-wiki-nested","slug":"nested-wiki","is_new":true,"new_md":"# Nested wiki entry\n\nDrafted by a sub-agent.\n"}} +{"action":"proposal","name":"mcp__triagent-strategies__playbook_proposal_draft","toolId":"pb-invalid","parentToolId":"dispatch-nested","input":{"playbook_id":"investigation"},"result":{"message":"","new_yaml":"","playbook_id":"","proposal_id":"","type":"","validation_errors":["parse input: yaml: unmarshal errors:\n line 7: cannot unmarshal !!seq into map[string]strategies.Node"]}} +{"action":"proposal","name":"mcp__triagent-strategies__playbook_proposal_draft","toolId":"pb-nested","parentToolId":"dispatch-nested","input":{"playbook_id":"investigation"},"result":{"proposal_id":"prop-pb-nested","playbook_id":"investigation","new_yaml":"id: investigation\nsteps:\n - id: triage\n advice: drafted by a sub-agent\n"}} +{"action":"proposal","name":"mcp__triagent-git-payments__draft_pr","toolId":"cf-nested","parentToolId":"dispatch-nested","input":{"issue_number":52},"result":{"proposal_id":"prop-cf-nested","repo":"acme/payments","issue_url":"https://github.com/acme/payments/issues/52","issue_number":52,"pr_url":"https://github.com/acme/payments/pull/53","pr_number":53,"branch_name":"fix/nested-codefix","summary":"Nested codefix proposal drafted in a sub-agent."}} +{"action":"emit","event":{"type":"end"}} +{"action":"exit","code":0} diff --git a/e2e/proposal_surfacing_test.go b/e2e/proposal_surfacing_test.go new file mode 100644 index 00000000..043f267b --- /dev/null +++ b/e2e/proposal_surfacing_test.go @@ -0,0 +1,110 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/sourcehawk/triagent/e2e/harness" +) + +// TestProposalSurfacing_NestedBackendInvariants drives a turn whose +// proposals are drafted INSIDE a walk_playbook sub-agent dispatch (their +// tool-events carry parentToolId=dispatch-nested). This is the shape that +// regressed: a sub-agent-drafted proposal never surfaced. The launcher's +// nesting-independent surfacing paths must still fire end to end: +// +// - codefix: draft_pr's end-event persists the proposal regardless of +// nesting, so /api/codefix-proposals lists it. +// - wiki: propose_wiki_draft's end-event fans a wiki_proposal_created +// global event so the sidebar pending list refreshes live. +// +// The inline cards (wiki + playbook) are hoisted out of the nesting on the +// frontend; that DOM half is pinned in the browser layer +// (TestProposalSurfacing_NestedBrowser). +func TestProposalSurfacing_NestedBackendInvariants(t *testing.T) { + h := harness.Launch(t, harness.Options{ + Profile: "with-prompts-and-linked-repo", + StubScript: "nested-proposals", + }) + + // Subscribe before starting so the mid-turn global event isn't missed. + stream := h.Client.OpenStream(t) + defer stream.Close() + + id := createInvestigation(t, h) + status, body := h.Client.PostJSON(t, "/api/investigations/"+id+"/start", nil) + if status != http.StatusAccepted { + t.Fatalf("start session status = %d (body %s)", status, body) + } + + // The nested wiki proposal fans a global wiki_proposal_created event — + // the sidebar-refresh path that does not depend on transcript nesting. + ev := stream.WaitForKind(t, "wiki_proposal_created", 30*time.Second) + var env struct { + WikiProposalCreated struct { + ProposalID string `json:"proposalID"` + InvestigationID string `json:"investigationID"` + } `json:"wikiProposalCreated"` + } + if err := json.Unmarshal(ev.Data, &env); err != nil { + t.Fatalf("decode wiki_proposal_created envelope: %v (data %s)", err, ev.Data) + } + if env.WikiProposalCreated.ProposalID != "prop-wiki-nested" { + t.Errorf("wiki_proposal_created proposalID = %q, want prop-wiki-nested", env.WikiProposalCreated.ProposalID) + } + if env.WikiProposalCreated.InvestigationID != id { + t.Errorf("wiki_proposal_created investigationID = %q, want %q", env.WikiProposalCreated.InvestigationID, id) + } + + waitForEnd(t, stream, id) + + // The nested codefix proposal persisted from its draft_pr end-event, + // surfacing on the repos activity panel despite being nested. + assertCodefixProposalPersisted(t, h, codefixWant{ + proposalID: "prop-cf-nested", repo: "acme/payments", prNumber: 53, issueNumber: 52, + }) + + // All three proposal tool calls reached the transcript (nested under the + // dispatch) — the precondition that makes the surfacing assertions above + // meaningful rather than vacuous. + for _, name := range []string{ + "mcp__triagent-strategies__playbook_proposal_draft", + "mcp__triagent-wiki__propose_wiki_draft", + "mcp__triagent-git-payments__draft_pr", + } { + if !transcriptHasToolCall(t, h, id, name) { + t.Errorf("nested proposal %q missing from transcript", name) + } + } +} + +// TestProposalSurfacing_NestedBrowser pins the DOM half: a wiki and a +// playbook proposal drafted inside a walk_playbook sub-agent dispatch (nested +// tool-events) still render their inline cards in the session view, because +// the transcript folder hoists them out of the nesting. The Go side launches +// the seeded launcher + scripted stub; the Playwright spec drives the SPA. +func TestProposalSurfacing_NestedBrowser(t *testing.T) { + h := harness.Launch(t, harness.Options{ + Profile: "with-prompts-and-linked-repo", + StubScript: "nested-proposals", + Browser: true, + }) + h.Browser.Run(t, "nested-proposals.spec.ts") +} + +// transcriptHasToolCall reports whether the investigation transcript carries +// a tool_use for the named tool (nested or not — the REST transcript flattens +// every event). +func transcriptHasToolCall(t *testing.T, h *harness.Harness, id, toolName string) bool { + t.Helper() + for _, e := range fetchTranscript(t, h, id) { + if e.Kind == "tool_use" && e.ToolName == toolName { + return true + } + } + return false +} diff --git a/e2e/proposal_view_test.go b/e2e/proposal_view_test.go new file mode 100644 index 00000000..4183954c --- /dev/null +++ b/e2e/proposal_view_test.go @@ -0,0 +1,72 @@ +//go:build e2e + +package e2e + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sourcehawk/triagent/e2e/harness" +) + +// TestPlaybookProposalView_NewPlaybookOpensFromSidebar pins the bug where a +// pending proposal for a brand-new playbook (no live playbook with that id) +// failed to open: clicking it deep-linked into the editor, which called +// getPlaybook() -> 404 and showed "playbook not found". The editor now +// falls back to seeding from the proposal draft. The Go side seeds the vault +// (a proposal with no base playbook) and runs the Playwright spec. +func TestPlaybookProposalView_NewPlaybookOpensFromSidebar(t *testing.T) { + h := harness.Launch(t, harness.Options{ + Profile: "minimal", + Playbook: "with-new-playbook-proposal", + Browser: true, + }) + h.Browser.Run(t, "playbook-proposal-view.spec.ts") +} + +// TestWikiProposalView_NewEntryOpens is the wiki twin: a pending proposal for +// a brand-new wiki entry (no entry with that slug on disk) must open from its +// sidenav deep-link. The wiki backend returns a synthetic is_stub entry rather +// than 404, so this protects that the new-entry proposal path keeps working. +func TestWikiProposalView_NewEntryOpens(t *testing.T) { + const slug = "synthetic-new-wiki-entry" + const proposalID = "prop-5e7714e10b2c" + h := harness.Launch(t, harness.Options{ + Profile: "minimal", + Browser: true, + }) + seedNewWikiProposal(t, h, "minimal", proposalID, slug) + if !wikiProposalPending(t, h, proposalID) { + t.Fatalf("seeded wiki proposal %q not pending server-side before browser run", proposalID) + } + h.Browser.SetEnv("TRIAGENT_WIKI_SLUG", slug) + h.Browser.SetEnv("TRIAGENT_WIKI_PROPOSAL_ID", proposalID) + h.Browser.Run(t, "wiki-proposal-view.spec.ts") +} + +// seedNewWikiProposal writes a draft for a brand-new entry (no base file) into +// the wiki-proposals dir as __.md — the on-disk shape +// handleListWikiProposals enumerates. Content is written directly (not from a +// fixture) since the slug intentionally has no matching vault entry. +func seedNewWikiProposal(t *testing.T, h *harness.Harness, profile, proposalID, slug string) { + t.Helper() + dir := wikiProposalsDir(h, profile) + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatalf("mkdir wiki-proposals dir: %v", err) + } + body := "---\n" + + "schema_version: 1\n" + + "id: " + slug + "\n" + + "date: \"2026-06-04\"\n" + + "title: Synthetic new wiki entry proposal\n" + + "status: resolved\n" + + "severity: sev3\n" + + "---\n\n" + + "## Summary\n\n" + + "A brand-new wiki entry proposed by the agent; no entry with this slug exists on disk yet, so opening the proposal must render from the draft.\n\n" + + "## Root cause\n\nSynthetic.\n" + if err := os.WriteFile(filepath.Join(dir, proposalID+"__"+slug+".md"), []byte(body), 0o644); err != nil { + t.Fatalf("write new wiki proposal: %v", err) + } +} diff --git a/frontend/app/(main)/layout.tsx b/frontend/app/(main)/layout.tsx index cda1df5f..39252a11 100644 --- a/frontend/app/(main)/layout.tsx +++ b/frontend/app/(main)/layout.tsx @@ -8,6 +8,7 @@ import { NewTypeModal } from "@/components/NewTypeModal"; import { NewWikiEntryModal } from "@/components/wiki/NewWikiEntryModal"; import { RepoSummaryStateProvider } from "@/components/RepoSummaryStateProvider"; import { CodefixPRStateProvider } from "@/components/CodefixPRStateProvider"; +import { WikiProposalNotifier } from "@/components/WikiProposalNotifier"; import { StreamProvider } from "@/lib/stream"; import { api } from "@/lib/api"; @@ -89,6 +90,7 @@ export default function MainLayout({ + )} + diff --git a/frontend/app/(main)/repos/client.tsx b/frontend/app/(main)/repos/client.tsx index 0b259b3b..3f2db879 100644 --- a/frontend/app/(main)/repos/client.tsx +++ b/frontend/app/(main)/repos/client.tsx @@ -10,6 +10,7 @@ import { } from "@/lib/api"; import { GitHubIcon, WarningIcon } from "@/components/Icons"; import { Spinner } from "@/components/Spinner"; +import { notifyReposChanged, onReposChanged } from "@/lib/repos-events"; import { useDialog } from "@/lib/dialog"; import { repoKey, @@ -43,8 +44,11 @@ export function ReposIndexClient() { // staring at "page 3 of 2". const [defaultsPage, setDefaultsPage] = useState(0); const [userPage, setUserPage] = useState(0); - // Bumped whenever a remove succeeds so the load effect re-runs. + // Bumped to re-run the load effect: on this page's own remove, and on + // any add/remove from another surface (the sidebar's manage-repos + // modal) so a repo linked there shows up here without a page reload. const [reloadNonce, setReloadNonce] = useState(0); + useEffect(() => onReposChanged(() => setReloadNonce((n) => n + 1)), []); // Free-text search applied to BOTH sections. Empty string = no // filter. Matches owner, name, alias, and description so operators // can find a repo by any of the surfaces visible on the row. @@ -137,7 +141,7 @@ export function ReposIndexClient() { setRemoving(key); try { await api.removeRepo(repo.owner, repo.name); - setReloadNonce((n) => n + 1); + notifyReposChanged(); } catch (e) { const msg = e instanceof ApiError ? e.message : String(e); await dialog.alert({ diff --git a/frontend/components/LinkedReposPanel.tsx b/frontend/components/LinkedReposPanel.tsx index ee3bd47d..47f03ece 100644 --- a/frontend/components/LinkedReposPanel.tsx +++ b/frontend/components/LinkedReposPanel.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { api, ApiError, type Investigation, type LinkedRepo, type RepoSummaryStatus } from "@/lib/api"; +import { notifyReposChanged, onReposChanged } from "@/lib/repos-events"; import { useDialog } from "@/lib/dialog"; import { Spinner } from "./Spinner"; import { @@ -132,6 +133,10 @@ export function LinkedReposPanel({ investigation, refreshNonce }: Props) { function PendingReposList({ refreshNonce }: { refreshNonce?: number }) { const [repos, setRepos] = useState(null); const [err, setErr] = useState(null); + // Refetch when a repo is added/removed from any surface (the manage + // modal here, or the /repos page) so the sidebar list stays in sync. + const [changeNonce, setChangeNonce] = useState(0); + useEffect(() => onReposChanged(() => setChangeNonce((n) => n + 1)), []); useEffect(() => { let cancelled = false; @@ -151,7 +156,7 @@ function PendingReposList({ refreshNonce }: { refreshNonce?: number }) { return () => { cancelled = true; }; - }, [refreshNonce]); + }, [refreshNonce, changeNonce]); if (err) { return ( @@ -336,6 +341,11 @@ function ManageReposModal({ const [alias, setAlias] = useState(""); const [description, setDescription] = useState(""); + // changeNonce reloads the modal both on its own add/remove and on + // changes made elsewhere (e.g. the /repos page) while it's open. + const [changeNonce, setChangeNonce] = useState(0); + useEffect(() => onReposChanged(() => setChangeNonce((n) => n + 1)), []); + function reload() { setLoadErr(null); api @@ -349,7 +359,7 @@ function ManageReposModal({ useEffect(() => { reload(); - }, [refreshNonce]); + }, [refreshNonce, changeNonce]); async function add(e: React.FormEvent) { e.preventDefault(); @@ -368,7 +378,7 @@ function ManageReposModal({ setName(""); setAlias(""); setDescription(""); - reload(); + notifyReposChanged(); } catch (err) { setActionErr(err instanceof ApiError ? err.message : String(err)); } finally { @@ -388,7 +398,7 @@ function ManageReposModal({ setActionErr(null); try { await api.removeRepo(r.owner, r.name); - reload(); + notifyReposChanged(); } catch (err) { setActionErr(err instanceof ApiError ? err.message : String(err)); } finally { diff --git a/frontend/components/PlaybookEditor.tsx b/frontend/components/PlaybookEditor.tsx index b057d1f2..45966b60 100644 --- a/frontend/components/PlaybookEditor.tsx +++ b/frontend/components/PlaybookEditor.tsx @@ -634,8 +634,37 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) // No-op restore lets the persist effect skip its own write below. void restored; }) - .catch((e) => { + .catch(async (e) => { if (cancelled) return; + // A brand-new playbook proposal has no live playbook by this id yet, + // so getPlaybook 404s. When we're deep-linked to that proposal + // (?proposal=&tab=proposal), seed the editor from the proposal's + // draft — like __new — instead of showing "playbook not found". + if (e instanceof ApiError && e.status === 404 && initialProposalID) { + try { + const res = await api.getPlaybookProposal(initialProposalID); + if (cancelled) return; + if (res.status === "pending" && res.new_yaml) { + const parsed = parsePlaybookYAML(res.new_yaml); + if (res.type) parsed.type = res.type; + dispatch({ type: "set", next: parsed }); + setLoad({ + kind: "loaded", + original: parsed, + source: "user", + locked: false, + isNew: true, + disabled: false, + commits: [], + }); + setSyncState({ status: "local-only", reason: "not yet on upstream" }); + setSelectedNode(parsed.entrypoint); + return; + } + } catch { + /* fall through to the error state below */ + } + } setLoad({ kind: "error", message: e instanceof ApiError ? e.message : String(e), @@ -645,7 +674,7 @@ export function PlaybookEditor({ id, onBack, onMutated, onOpenPlaybook }: Props) return () => { cancelled = true; }; - }, [id, refetchKey]); + }, [id, refetchKey, initialProposalID]); // Chat-alive gate: once the operator has opened a chat session for // some playbook, the drawer's session is anchored to THAT subject diff --git a/frontend/components/SessionView.tsx b/frontend/components/SessionView.tsx index cc33a653..fe4436f6 100644 --- a/frontend/components/SessionView.tsx +++ b/frontend/components/SessionView.tsx @@ -1577,7 +1577,12 @@ const TranscriptItemView = memo(function TranscriptItemView({ ) { try { const payload = JSON.parse(item.result) as ProposalDraftPayload; - if (payload && typeof payload.proposal_id === "string") { + // A non-empty proposal_id means the draft was accepted and + // persisted. A validation-failure result carries proposal_id:"" + // (plus validation_errors); it must NOT render as a proposal card — + // fall through to the raw tool card so the failed attempt is visible + // as activity, not an empty approve/decline card. + if (payload && typeof payload.proposal_id === "string" && payload.proposal_id.length > 0) { return (
0) { return (
0) { return (
diff --git a/frontend/components/Sidebar.tsx b/frontend/components/Sidebar.tsx index 2e129612..a6354fe5 100644 --- a/frontend/components/Sidebar.tsx +++ b/frontend/components/Sidebar.tsx @@ -106,13 +106,27 @@ function InvRow({ inv, active, onSelect, onDelete, onRenamed }: InvRowProps) { return (
  • - +
  • ); } diff --git a/frontend/components/WatchForm.test.tsx b/frontend/components/WatchForm.test.tsx index a79ed845..384cb670 100644 --- a/frontend/components/WatchForm.test.tsx +++ b/frontend/components/WatchForm.test.tsx @@ -1,19 +1,37 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { api } from "@/lib/api"; import { WatchForm } from "./WatchForm"; describe("WatchForm", () => { - it("renders github fields when kind is github", () => { + beforeEach(() => { + // The mount effect fetches connection status and setState's on it. + // Stub it so the async update is deterministic, then flush it inside + // act() (flushEffects) so React doesn't warn about an unwrapped update. + vi.spyOn(api, "getConnections").mockResolvedValue({ + slack: false, + incidentio: false, + slack_channel_prefix: "", + }); + }); + + // flushEffects settles the mount effect's connection fetch inside act so + // its trailing setConn doesn't land outside act(). + const flushEffects = () => act(async () => {}); + + it("renders github fields when kind is github", async () => { render(); expect(screen.getByLabelText(/owner/i)).toBeInTheDocument(); expect(screen.getByLabelText(/repo/i)).toBeInTheDocument(); + await flushEffects(); }); - it("switches to slack fields when kind=slack_channel selected", () => { + it("switches to slack fields when kind=slack_channel selected", async () => { render(); fireEvent.click(screen.getByLabelText(/slack channel/i)); expect(screen.getByLabelText(/channel id/i)).toBeInTheDocument(); + await flushEffects(); }); - it("posts form on submit", () => { + it("posts form on submit", async () => { const onSubmit = vi.fn(); render(); fireEvent.change(screen.getByLabelText(/name/i), { target: { value: "n" } }); @@ -24,5 +42,6 @@ describe("WatchForm", () => { const arg = onSubmit.mock.calls[0][0]; expect(arg.name).toBe("n"); expect(arg.source.kind).toBe("github_issues"); + await flushEffects(); }); }); diff --git a/frontend/components/WikiProposalNotifier.test.tsx b/frontend/components/WikiProposalNotifier.test.tsx new file mode 100644 index 00000000..de696280 --- /dev/null +++ b/frontend/components/WikiProposalNotifier.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render } from "@testing-library/react"; +import type { StreamEnvelope } from "@/lib/api"; +import type { StreamFilter } from "@/lib/stream"; + +type Handler = (env: StreamEnvelope) => void; + +// Capture subscribed handlers so the test can synthesize stream envelopes. +const subscribers: { filter: StreamFilter; handler: Handler }[] = []; +vi.mock("@/lib/stream", () => ({ + useStream: () => ({ + subscribe: (filter: StreamFilter, handler: Handler) => { + const entry = { filter, handler }; + subscribers.push(entry); + return () => { + const idx = subscribers.indexOf(entry); + if (idx >= 0) subscribers.splice(idx, 1); + }; + }, + }), +})); + +function emit(env: Partial & { kind: string }) { + for (const s of subscribers) s.handler(env as StreamEnvelope); +} + +import { WikiProposalNotifier } from "./WikiProposalNotifier"; + +beforeEach(() => { + subscribers.length = 0; +}); + +describe("WikiProposalNotifier", () => { + it("dispatches c1:wiki-proposals-changed on a wiki_proposal_created envelope", () => { + render(child); + const onChange = vi.fn(); + window.addEventListener("c1:wiki-proposals-changed", onChange); + + emit({ kind: "wiki_proposal_created", wikiProposalCreated: { proposalID: "prop-1" } }); + expect(onChange).toHaveBeenCalledTimes(1); + + window.removeEventListener("c1:wiki-proposals-changed", onChange); + }); + + it("ignores unrelated global envelopes", () => { + render(child); + const onChange = vi.fn(); + window.addEventListener("c1:wiki-proposals-changed", onChange); + + emit({ kind: "watch_status" }); + expect(onChange).not.toHaveBeenCalled(); + + window.removeEventListener("c1:wiki-proposals-changed", onChange); + }); + + it("subscribes on the global scope", () => { + render(child); + expect(subscribers).toHaveLength(1); + expect(subscribers[0].filter).toEqual({ scope: "global" }); + }); +}); diff --git a/frontend/components/WikiProposalNotifier.tsx b/frontend/components/WikiProposalNotifier.tsx new file mode 100644 index 00000000..1eed2d6e --- /dev/null +++ b/frontend/components/WikiProposalNotifier.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { useStream } from "@/lib/stream"; + +// WikiProposalNotifier bridges the launcher's wiki_proposal_created global +// event to the c1:wiki-proposals-changed DOM event the sidebar's pending list +// already listens for. A proposal drafted inside a playbook sub-agent surfaces +// live this way, independent of whether its (nested) transcript card rendered. +export function WikiProposalNotifier({ children }: { children: ReactNode }) { + const stream = useStream(); + useEffect(() => { + return stream.subscribe({ scope: "global" }, (env) => { + if (env.kind !== "wiki_proposal_created") return; + window.dispatchEvent(new CustomEvent("c1:wiki-proposals-changed")); + }); + }, [stream]); + return <>{children}; +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 0b438f2f..1cff63e2 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -450,6 +450,14 @@ export type StreamEnvelope = { durationMs: number; error?: string; }; + // wikiProposalCreated rides on wiki_proposal_created global envelopes — + // fired when a propose_wiki_draft call lands a new draft. The sidebar's + // pending-proposals list refetches on it so a proposal surfaces live even + // when it was drafted inside a playbook sub-agent. + wikiProposalCreated?: { + proposalID?: string; + investigationID?: string; + }; // usage rides on assistant + result envelopes — claude's per-message // and per-CLI-invocation token tallies. Subscribers that care about // running session totals (Sidebar, SessionView footer) sum costUsd + diff --git a/frontend/lib/events.test.ts b/frontend/lib/events.test.ts index 9915c273..2b87f028 100644 --- a/frontend/lib/events.test.ts +++ b/frontend/lib/events.test.ts @@ -1,5 +1,12 @@ import { describe, it, expect } from "vitest"; -import { applyEvent, isDraftPrToolName, type EventEnvelope, type TranscriptItem } from "./events"; +import { + applyEvent, + isDraftPrToolName, + PROPOSE_WIKI_DRAFT_TOOL_NAME, + PROPOSE_PLAYBOOK_DRAFT_TOOL_NAME, + type EventEnvelope, + type TranscriptItem, +} from "./events"; function envelope(partial: Partial & Pick): EventEnvelope { return { @@ -41,6 +48,57 @@ describe("applyEvent — tool_status routing", () => { ]); }); + // A proposal drafted inside the wiki_proposal playbook runs in a sub-agent, + // so its propose_wiki_draft call arrives with a parentToolId (the dispatch). + // Nesting it would bury the structured result and the inline WikiProposalCard + // would never render — the exact bug. Proposal-draft calls must escape + // nesting and surface as a top-level tool_call carrying their result. + it("hoists a nested propose_wiki_draft call to a top-level tool_call with its result", () => { + let items: TranscriptItem[] = []; + // The sub-agent dispatch (e.g. walk_playbook) is the parent. + items = applyEvent(items, envelope({ + kind: "tool_use", + seq: 1, + toolId: "dispatch", + toolName: "mcp__triagent-strategies__walk_playbook", + })); + items = applyEvent(items, envelope({ + kind: "tool_use", + seq: 2, + toolId: "sub_propose", + parentToolId: "dispatch", + toolName: PROPOSE_WIKI_DRAFT_TOOL_NAME, + })); + items = applyEvent(items, envelope({ + kind: "tool_result", + seq: 3, + toolId: "sub_propose", + parentToolId: "dispatch", + text: '{"kind":"wiki_proposal_draft","proposal_id":"prop-1","slug":"x"}', + })); + + const card = items.find( + (it) => it.kind === "tool_call" && it.name === PROPOSE_WIKI_DRAFT_TOOL_NAME, + ); + expect(card).toBeDefined(); + if (card?.kind !== "tool_call") throw new Error("expected top-level tool_call"); + expect(card.result).toContain("prop-1"); + // It must NOT also be buried as a child of the dispatch. + const dispatch = items.find((it) => it.kind === "tool_call" && it.toolId === "dispatch"); + if (dispatch?.kind !== "tool_call") throw new Error("expected dispatch tool_call"); + expect(dispatch.children ?? []).toHaveLength(0); + }); + + it("hoists a nested playbook_proposal_draft call the same way", () => { + let items: TranscriptItem[] = []; + items = applyEvent(items, envelope({ kind: "tool_use", seq: 1, toolId: "dispatch", toolName: "mcp__triagent-strategies__walk_playbook" })); + items = applyEvent(items, envelope({ kind: "tool_use", seq: 2, toolId: "sub_pb", parentToolId: "dispatch", toolName: PROPOSE_PLAYBOOK_DRAFT_TOOL_NAME })); + items = applyEvent(items, envelope({ kind: "tool_result", seq: 3, toolId: "sub_pb", parentToolId: "dispatch", text: '{"proposal_id":"pp-1"}' })); + + const card = items.find((it) => it.kind === "tool_call" && it.name === PROPOSE_PLAYBOOK_DRAFT_TOOL_NAME); + expect(card?.kind === "tool_call" && card.result).toContain("pp-1"); + }); + it("drops tool_status without parentToolId silently (no crash, no top-level item)", () => { const items = applyEvent([], envelope({ kind: "tool_status", diff --git a/frontend/lib/events.ts b/frontend/lib/events.ts index 12341b15..eedd8f6f 100644 --- a/frontend/lib/events.ts +++ b/frontend/lib/events.ts @@ -186,6 +186,17 @@ export const PROPOSE_PLAYBOOK_DRAFT_TOOL_NAME = export const PROPOSE_WIKI_DRAFT_TOOL_NAME = "mcp__triagent-wiki__propose_wiki_draft"; +// A proposal-draft tool result is a top-level investigation artifact even +// when the call happens inside a sub-agent dispatch (e.g. the wiki_proposal / +// playbook_proposal walker). The transcript folder hoists these out of the +// dispatch's nested children so the inline card renders; see applyEvent. +export function isProposalDraftToolName(name: string | undefined): boolean { + return ( + name === PROPOSE_WIKI_DRAFT_TOOL_NAME || + name === PROPOSE_PLAYBOOK_DRAFT_TOOL_NAME + ); +} + // Wire-format suffix of the per-repo draft_pr tool. Since each linked // repo gets its own triagent-git- MCP server (one process per repo), // the full tool name varies (e.g. `mcp__triagent-git-zeebe__draft_pr`, @@ -251,12 +262,30 @@ export function applyEvent( // parent must already exist (parent tool_use precedes its children in // the wire ordering); if it doesn't, we drop the event silently rather // than synthesising a top-level item that would confuse the UI. + // + // Exception: a proposal-draft call (and its result) is a top-level + // investigation artifact even when it runs inside a sub-agent dispatch. + // Burying it would hide the inline approve/decline card, so it escapes + // nesting and falls through to the top-level switch below. We key the + // result's escape off the hoisted tool_call already present at top level + // (matched by toolId) since result envelopes don't carry the tool name. if (ev.parentToolId && (ev.kind === "tool_use" || ev.kind === "tool_result" || ev.kind === "assistant" || ev.kind === "tool_status")) { - return items.map((it) => - it.kind === "tool_call" && it.toolId === ev.parentToolId - ? { ...it, children: applyNested(it.children ?? [], ev) } - : it, - ); + const escapesNesting = + (ev.kind === "tool_use" && isProposalDraftToolName(ev.toolName)) || + (ev.kind === "tool_result" && + items.some( + (it) => + it.kind === "tool_call" && + it.toolId === ev.toolId && + isProposalDraftToolName(it.name), + )); + if (!escapesNesting) { + return items.map((it) => + it.kind === "tool_call" && it.toolId === ev.parentToolId + ? { ...it, children: applyNested(it.children ?? [], ev) } + : it, + ); + } } switch (ev.kind) { diff --git a/frontend/lib/repos-events.test.ts b/frontend/lib/repos-events.test.ts new file mode 100644 index 00000000..c7e56e6c --- /dev/null +++ b/frontend/lib/repos-events.test.ts @@ -0,0 +1,37 @@ +/* @vitest-environment jsdom */ +import { describe, it, expect, vi } from "vitest"; +import { REPOS_CHANGED_EVENT, notifyReposChanged, onReposChanged } from "./repos-events"; + +describe("repos-events", () => { + it("fires subscribers when notifyReposChanged is called", () => { + const cb = vi.fn(); + const off = onReposChanged(cb); + notifyReposChanged(); + expect(cb).toHaveBeenCalledTimes(1); + off(); + }); + + it("delivers to every subscriber so independent repo lists all refetch", () => { + const a = vi.fn(); + const b = vi.fn(); + const offA = onReposChanged(a); + const offB = onReposChanged(b); + notifyReposChanged(); + expect(a).toHaveBeenCalledTimes(1); + expect(b).toHaveBeenCalledTimes(1); + offA(); + offB(); + }); + + it("stops delivering after unsubscribe", () => { + const cb = vi.fn(); + const off = onReposChanged(cb); + off(); + notifyReposChanged(); + expect(cb).not.toHaveBeenCalled(); + }); + + it("uses the triagent: DOM-event prefix (ADR-0005)", () => { + expect(REPOS_CHANGED_EVENT).toBe("triagent:repos-changed"); + }); +}); diff --git a/frontend/lib/repos-events.ts b/frontend/lib/repos-events.ts new file mode 100644 index 00000000..ea9c34b0 --- /dev/null +++ b/frontend/lib/repos-events.ts @@ -0,0 +1,25 @@ +// Cross-surface refresh signal for linked-repo lists. The sidebar +// (PendingReposList / ManageReposModal) and the /repos page each render an +// independent list from the same /api/repos data, so a repo added or removed +// on one surface must tell the others to refetch — otherwise the change only +// appears after a full page reload. +// +// DOM events use the triagent: prefix (ADR-0005); the c1: events predate the +// project rename and aren't a template for new ones. +export const REPOS_CHANGED_EVENT = "triagent:repos-changed"; + +// notifyReposChanged fires the refresh signal. Call it after any successful +// add/remove. dispatchEvent is synchronous, so every mounted listener — +// including the caller's own — refetches within this call stack. +export function notifyReposChanged(): void { + if (typeof window === "undefined") return; + window.dispatchEvent(new Event(REPOS_CHANGED_EVENT)); +} + +// onReposChanged subscribes cb to the refresh signal and returns an +// unsubscribe fn — drop it straight into a useEffect cleanup. +export function onReposChanged(cb: () => void): () => void { + if (typeof window === "undefined") return () => {}; + window.addEventListener(REPOS_CHANGED_EVENT, cb); + return () => window.removeEventListener(REPOS_CHANGED_EVENT, cb); +} diff --git a/frontend/lib/stream.test.ts b/frontend/lib/stream.test.ts new file mode 100644 index 00000000..f500d8e1 --- /dev/null +++ b/frontend/lib/stream.test.ts @@ -0,0 +1,38 @@ +import { describe, it, expect } from "vitest"; +import { STREAM_EVENT_KINDS } from "./stream"; + +// The single EventSource registers one listener per kind (see StreamProvider). +// A kind absent from this list is delivered to no listener and silently +// dropped — its live updates only appear after a /transcript refetch. That is +// exactly how codefix PR-state and auto-mode transitions regressed. Every kind +// with a live consumer MUST be listed; this test pins that contract. +describe("STREAM_EVENT_KINDS", () => { + // Each entry: the kind and the consumer that subscribes to it live. + const requiredLiveKinds: Array<[string, string]> = [ + ["auto_mode_state", "useAutoMode (composer gating + phase dividers)"], + ["codefix_pr_state", "CodefixPRStateProvider (PR lifecycle)"], + ["repo_summary_state", "RepoSummaryStateProvider"], + ["wiki_proposal_created", "WikiProposalNotifier (pending-proposals refresh)"], + ["watch_status", "watches list"], + ["signal_created", "AllWatchesSignalsPanel"], + ["item_captured", "watch ingest panels"], + ["ingest_run_started", "WatchIngestRunsPanel"], + ["ingest_run_finished", "WatchIngestRunsPanel"], + // Core transcript kinds folded by applyEvent. + ["tool_use", "transcript"], + ["tool_result", "transcript"], + ["tool_status", "transcript nested status"], + ["assistant", "transcript"], + ["user", "transcript"], + ]; + + for (const [kind, consumer] of requiredLiveKinds) { + it(`registers "${kind}" — consumed by ${consumer}`, () => { + expect(STREAM_EVENT_KINDS).toContain(kind); + }); + } + + it("has no duplicate kinds", () => { + expect(new Set(STREAM_EVENT_KINDS).size).toBe(STREAM_EVENT_KINDS.length); + }); +}); diff --git a/frontend/lib/stream.tsx b/frontend/lib/stream.tsx index f65fa6be..71b964e9 100644 --- a/frontend/lib/stream.tsx +++ b/frontend/lib/stream.tsx @@ -25,6 +25,41 @@ type StreamContext = { const Ctx = createContext(null); +// STREAM_EVENT_KINDS is the exhaustive set of SSE event names the single +// EventSource listens for. The server names every frame with its Kind +// (writeStreamSSE sets `event: `), and EventSource only delivers a frame +// to a listener registered for that exact name — so a kind missing here is +// silently dropped and its live updates appear only after a /transcript +// refetch. Keep this in sync with the Go envKind* (per-investigation) and +// globalKind* (launcher-wide) constants; lib/stream.test.ts pins the kinds +// that have live consumers. +export const STREAM_EVENT_KINDS = [ + // Per-investigation transcript kinds (Go envKind*). + "system", + "assistant", + "tool_use", + "tool_result", + "tool_status", + "result", + "error", + "user", + "end", + "push_state", + "rehydrate_state", + "label", + "auto_mode_state", + "usage", + // Launcher-wide kinds (Go globalKind*). + "repo_summary_state", + "codefix_pr_state", + "watch_status", + "signal_created", + "item_captured", + "ingest_run_started", + "ingest_run_finished", + "wiki_proposal_created", +] as const; + // StreamProvider owns the single EventSource at /api/stream for the // page lifetime. Consumers subscribe via useStream() with a filter; // their handler receives every envelope matching that filter. @@ -78,33 +113,12 @@ export function StreamProvider({ children }: { children: ReactNode }) { }; // The server sets event: on every frame (see writeStreamSSE). - const kinds = [ - "system", - "assistant", - "tool_use", - "tool_result", - "result", - "error", - "user", - "end", - "push_state", - "rehydrate_state", - "label", - "repo_summary_state", - "tool_status", - "watch_status", - "signal_created", - "item_captured", - "ingest_run_started", - "ingest_run_finished", - "usage", - ]; - for (const k of kinds) { + for (const k of STREAM_EVENT_KINDS) { es.addEventListener(k, dispatch as EventListener); } return () => { - for (const k of kinds) { + for (const k of STREAM_EVENT_KINDS) { es.removeEventListener(k, dispatch as EventListener); } es.close(); diff --git a/internal/preflight/mcpconfig.go b/internal/preflight/mcpconfig.go index 6277493d..fedcf8f3 100644 --- a/internal/preflight/mcpconfig.go +++ b/internal/preflight/mcpconfig.go @@ -81,6 +81,9 @@ const ( EnvParallelUpstreams = "TRIAGENT_MCP_PARALLEL_UPSTREAMS" + EnvTeleportProxy = "TRIAGENT_MCP_TELEPORT_PROXY" + EnvTeleportAuthConnector = "TRIAGENT_MCP_TELEPORT_AUTH_CONNECTOR" + EnvKubeconfig = "KUBECONFIG" EnvTelemetryURL = "TRIAGENT_MCP_TELEMETRY_URL" @@ -376,13 +379,28 @@ func writeMCPConfig(in mcpConfigInputs) (string, error) { }, } - teleportEnv := map[string]string{} - mergeEnv(teleportEnv, telemetryEnv(in, MCPAliasTeleport)) - mergeEnv(teleportEnv, kubeEnv(in)) - servers[MCPAliasTeleport] = map[string]any{ - "command": in.MCPBin, - "args": []string{"serve", "--kind=teleport"}, - "env": teleportEnv, + // Teleport tooling (list_clusters, login) only makes sense for a + // teleport deployment. On a kubeconfig deployment those tools have + // nothing to act on, and exposing them lures the agent into a tsh + // login loop instead of reaching for the k8s MCP's switch_context. + if in.Profile != nil && in.Profile.Auth.Kind == "teleport" { + teleportEnv := map[string]string{} + mergeEnv(teleportEnv, telemetryEnv(in, MCPAliasTeleport)) + mergeEnv(teleportEnv, kubeEnv(in)) + // Thread the deployment's connection parameters so the subprocess + // builds a provider that logs in against the right proxy/connector + // instead of the SDK's empty defaults. + if proxy := in.Profile.Auth.Teleport.Proxy; proxy != "" { + teleportEnv[EnvTeleportProxy] = proxy + } + if connector := in.Profile.Auth.Teleport.AuthConnector; connector != "" { + teleportEnv[EnvTeleportAuthConnector] = connector + } + servers[MCPAliasTeleport] = map[string]any{ + "command": in.MCPBin, + "args": []string{"serve", "--kind=teleport"}, + "env": teleportEnv, + } } if in.WikiPath != "" && in.WikiProposalsPath != "" { diff --git a/internal/preflight/mcpconfig_test.go b/internal/preflight/mcpconfig_test.go index 52e63cbf..a8871bf6 100644 --- a/internal/preflight/mcpconfig_test.go +++ b/internal/preflight/mcpconfig_test.go @@ -262,13 +262,14 @@ func TestWriteMCPConfig_IncludesParallelBroker(t *testing.T) { assert.True(t, hasK8s, "expected triagent-k8s among upstreams") } -func TestWriteMCPConfig_RegistersTeleport(t *testing.T) { +func TestWriteMCPConfig_RegistersTeleportWhenAuthKindTeleport(t *testing.T) { t.Parallel() dir := t.TempDir() mcpPath, err := writeMCPConfig(mcpConfigInputs{ Dir: dir, MCPBin: "/tmp/triagent-mcp", KubeconfigPath: "/tmp/kubeconfig", + Profile: &profile.Profile{Auth: profile.Auth{Kind: "teleport"}}, }) require.NoError(t, err) @@ -287,6 +288,66 @@ func TestWriteMCPConfig_RegistersTeleport(t *testing.T) { assert.Equal(t, "/tmp/kubeconfig", env["KUBECONFIG"], "teleport server needs the frozen kubeconfig") } +func TestWriteMCPConfig_TeleportCarriesProxyAndConnector(t *testing.T) { + t.Parallel() + mcpPath, err := writeMCPConfig(mcpConfigInputs{ + Dir: t.TempDir(), + MCPBin: "/tmp/triagent-mcp", + KubeconfigPath: "/tmp/kubeconfig", + Profile: &profile.Profile{Auth: profile.Auth{ + Kind: "teleport", + Teleport: profile.TeleportConfig{Proxy: "proxy.example.com", AuthConnector: "github"}, + }}, + }) + require.NoError(t, err) + + body, err := os.ReadFile(mcpPath) + require.NoError(t, err) + var cfg map[string]any + require.NoError(t, json.Unmarshal(body, &cfg)) + servers := cfg["mcpServers"].(map[string]any) + + env := servers[MCPAliasTeleport].(map[string]any)["env"].(map[string]any) + // Without these the subprocess builds an empty-config provider and the + // re-auth advice degrades to `tsh login --proxy= --auth=okta`. + assert.Equal(t, "proxy.example.com", env[EnvTeleportProxy], "profile proxy must reach the teleport subprocess") + assert.Equal(t, "github", env[EnvTeleportAuthConnector], "profile auth connector must reach the teleport subprocess") +} + +// A kubeconfig deployment must not expose the Teleport MCP — its list_clusters / +// login tools are meaningless there, and surfacing them lures the agent into a +// Teleport login loop. Context switching is the k8s MCP's switch_context. +func TestWriteMCPConfig_SkipsTeleportWhenAuthKindKubeconfig(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + profile *profile.Profile + }{ + {name: "kind kubeconfig", profile: &profile.Profile{Auth: profile.Auth{Kind: "kubeconfig"}}}, + {name: "no profile", profile: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + mcpPath, err := writeMCPConfig(mcpConfigInputs{ + Dir: t.TempDir(), + MCPBin: "/tmp/triagent-mcp", + KubeconfigPath: "/tmp/kubeconfig", + Profile: tc.profile, + }) + require.NoError(t, err) + + body, err := os.ReadFile(mcpPath) + require.NoError(t, err) + var cfg map[string]any + require.NoError(t, json.Unmarshal(body, &cfg)) + servers := cfg["mcpServers"].(map[string]any) + + _, ok := servers[MCPAliasTeleport] + assert.False(t, ok, "teleport MCP must not be registered for a kubeconfig deployment") + }) + } +} + func TestWriteMCPConfig_ExtraMCPs_SpawnMode_EmitsServerEntry(t *testing.T) { t.Setenv("FOO_URL", "http://localhost:9090") prof := &profile.Profile{ diff --git a/internal/server/global_events.go b/internal/server/global_events.go index 97912fbd..7130ebac 100644 --- a/internal/server/global_events.go +++ b/internal/server/global_events.go @@ -17,13 +17,14 @@ const ( // from the per-investigation envKind* set so a misrouted event would // stand out. const ( - globalKindRepoSummaryState = "repo_summary_state" - globalKindCodefixPRState = "codefix_pr_state" - globalKindWatchStatus = "watch_status" - globalKindSignalCreated = "signal_created" - globalKindItemCaptured = "item_captured" - globalKindIngestRunStarted = "ingest_run_started" - globalKindIngestRunFinished = "ingest_run_finished" + globalKindRepoSummaryState = "repo_summary_state" + globalKindCodefixPRState = "codefix_pr_state" + globalKindWatchStatus = "watch_status" + globalKindSignalCreated = "signal_created" + globalKindItemCaptured = "item_captured" + globalKindIngestRunStarted = "ingest_run_started" + globalKindIngestRunFinished = "ingest_run_finished" + globalKindWikiProposalCreated = "wiki_proposal_created" ) // GlobalEventEnvelope is the wire shape on /api/events. Mirrors @@ -41,11 +42,21 @@ type GlobalEventEnvelope struct { // CodefixPRState is set when Kind == "codefix_pr_state". CodefixPRState *CodefixPRStatePayload `json:"codefixPRState,omitempty"` - WatchStatus *WatchStatusEvent `json:"watchStatus,omitempty"` - SignalCreated *SignalCreatedEvent `json:"signalCreated,omitempty"` - ItemCaptured *ItemCapturedEvent `json:"itemCaptured,omitempty"` - IngestRunStarted *IngestRunStartedEvent `json:"ingestRunStarted,omitempty"` - IngestRunFinished *IngestRunFinishedEvent `json:"ingestRunFinished,omitempty"` + WatchStatus *WatchStatusEvent `json:"watchStatus,omitempty"` + SignalCreated *SignalCreatedEvent `json:"signalCreated,omitempty"` + ItemCaptured *ItemCapturedEvent `json:"itemCaptured,omitempty"` + IngestRunStarted *IngestRunStartedEvent `json:"ingestRunStarted,omitempty"` + IngestRunFinished *IngestRunFinishedEvent `json:"ingestRunFinished,omitempty"` + WikiProposalCreated *WikiProposalCreatedEvent `json:"wikiProposalCreated,omitempty"` +} + +// WikiProposalCreatedEvent fires when a propose_wiki_draft tool call lands a +// new draft on disk. The sidebar's pending-proposals list refetches on it, so +// a proposal surfaces live even when the draft was made inside a playbook +// sub-agent (whose nested tool result the transcript-card path can't see). +type WikiProposalCreatedEvent struct { + ProposalID string `json:"proposalID,omitempty"` + InvestigationID string `json:"investigationID,omitempty"` } // IngestRunStartedEvent fires when ClaudeIngestor.Run spawns claude. @@ -103,7 +114,7 @@ type ItemCapturedEvent struct { // reads it off /api/events to flip CodefixProposalCard lifecycle. type CodefixPRStatePayload struct { URL string `json:"url"` - State string `json:"state"` // "open" | "merged" | "closed" | "" (unknown) + State string `json:"state"` // "open" | "merged" | "closed" | "" (unknown) MergedAt *time.Time `json:"mergedAt,omitempty"` ClosedAt *time.Time `json:"closedAt,omitempty"` } @@ -162,4 +173,3 @@ func (r *globalRing) replay(now time.Time) []GlobalEventEnvelope { } return out } - diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 1a41bcd3..cb22e2de 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -823,6 +823,15 @@ func (a *apiHandlers) handleToolEvent(w http.ResponseWriter, r *http.Request) { a.persistCodefixProposal(body.TraceID, body.Result) } else if isCreateGithubIssueToolName(body.ToolName) { a.persistCodefixIssue(body.TraceID, body.Result) + } else if body.ToolName == proposeWikiDraftToolName { + // The wiki MCP already wrote the draft to disk; fan a global + // event so the sidebar's pending list refreshes live. This + // path is independent of transcript nesting, so it surfaces + // proposals drafted inside a playbook sub-agent too. + a.manager.PublishWikiProposalCreated(WikiProposalCreatedEvent{ + ProposalID: wikiProposalIDFromResult(body.Result), + InvestigationID: body.TraceID, + }) } } diff --git a/internal/server/handlers_tool_event_test.go b/internal/server/handlers_tool_event_test.go index e3248732..8768e035 100644 --- a/internal/server/handlers_tool_event_test.go +++ b/internal/server/handlers_tool_event_test.go @@ -68,3 +68,46 @@ func TestHandleToolEvent_StatusPhase_UnknownTraceID(t *testing.T) { a.handleToolEvent(w, req) require.Equal(t, http.StatusNotFound, w.Code) } + +// TestHandleToolEvent_WikiProposalDraftPublishesGlobalEvent verifies that a +// successful propose_wiki_draft end-event fans a global wiki_proposal_created +// envelope out so the sidebar's pending-proposals list refreshes — regardless +// of whether the draft was made directly or nested inside a playbook sub-agent +// (the nesting is what kept the inline card from rendering; this is the +// surfacing path that doesn't depend on transcript nesting at all). +func TestHandleToolEvent_WikiProposalDraftPublishesGlobalEvent(t *testing.T) { + t.Parallel() + mgr, inv := newTestManagerWithInvestigationForLabel(t) + a := &apiHandlers{ + manager: mgr, + telemetryToken: "test-token", + mcpHealth: newMCPHealth(), + } + + _, events, _, cancel := mgr.SubscribeStream("test-tok-"+t.Name(), 0) + t.Cleanup(cancel) + + // A nested call carries parentToolId; the global event must fire anyway. + body := strings.NewReader(`{"phase":"end","traceId":"` + inv.ID + `","toolId":"sub_toolu_01","parentToolId":"dispatch_1","toolName":"mcp__triagent-wiki__propose_wiki_draft","result":"{\"kind\":\"wiki_proposal_draft\",\"proposal_id\":\"prop-deadbeef\",\"slug\":\"x\"}"}`) + req := httptest.NewRequest(http.MethodPost, "/api/internal/tool-events", body) + req.Header.Set("Authorization", "Bearer test-token") + w := httptest.NewRecorder() + a.handleToolEvent(w, req) + require.Equal(t, http.StatusAccepted, w.Code) + + deadline := time.After(time.Second) + for { + select { + case env := <-events: + if env.Kind != globalKindWikiProposalCreated { + continue + } + require.NotNil(t, env.WikiProposalCreated) + require.Equal(t, "prop-deadbeef", env.WikiProposalCreated.ProposalID) + require.Equal(t, inv.ID, env.WikiProposalCreated.InvestigationID) + return + case <-deadline: + t.Fatal("timed out waiting for wiki_proposal_created global envelope") + } + } +} diff --git a/internal/server/handlers_wiki.go b/internal/server/handlers_wiki.go index 1124b92e..c68da423 100644 --- a/internal/server/handlers_wiki.go +++ b/internal/server/handlers_wiki.go @@ -13,6 +13,25 @@ import ( "gopkg.in/yaml.v3" ) +// proposeWikiDraftToolName is the wire-format name of the wiki MCP's draft +// tool. Mirrors the frontend PROPOSE_WIKI_DRAFT_TOOL_NAME constant; the +// tool-event handler keys the wiki_proposal_created global event off it. +const proposeWikiDraftToolName = "mcp__triagent-wiki__propose_wiki_draft" + +// wikiProposalIDFromResult pulls the proposal_id out of a propose_wiki_draft +// structured result. Best-effort: returns "" when the result isn't the +// expected JSON. The sidebar refetches the whole list regardless, so the id +// is for traceability only and never gates the refresh. +func wikiProposalIDFromResult(result string) string { + var parsed struct { + ProposalID string `json:"proposal_id"` + } + if err := json.Unmarshal([]byte(result), &parsed); err != nil { + return "" + } + return parsed.ProposalID +} + // marshalWikiFrontmatter marshals a wikiFrontmatter struct back to YAML text // (without trailing newline). Used by the resolve handler to rewrite the file. func marshalWikiFrontmatter(fm wikiFrontmatter) (string, error) { diff --git a/internal/server/manager.go b/internal/server/manager.go index 14b30a58..45ac851a 100644 --- a/internal/server/manager.go +++ b/internal/server/manager.go @@ -60,8 +60,8 @@ type OriginatingSignal struct { // draining any in-flight CLI work. type Investigation struct { // Immutable after Register; safe to read without holding the mutex. - ID string - Namespace string + ID string + Namespace string IncidentURL string SlackChannelURL string SlackChannelID string @@ -73,24 +73,24 @@ type Investigation struct { // Manager.SetLabel which serializes writes under inv.mu. Empty // until first set; the frontend renders "New Investigation" as a // placeholder rather than the empty string. - Label string - MCPConfigPath string - DocsPrefix string - SessionDir string + Label string + MCPConfigPath string + DocsPrefix string + SessionDir string SlackMCPEnabled bool IncidentioMCPEnabled bool - LinkedRepos []repos.LinkedRepo - Profile *profile.Profile // investigation profile (prompt content + playbook IDs) + LinkedRepos []repos.LinkedRepo + Profile *profile.Profile // investigation profile (prompt content + playbook IDs) // PromTarget is the per-investigation Prometheus port-forward target, // resolved from the profile defaults overlaid with any per-investigation // override supplied at preflight time. Nil means no prom target was // configured for this investigation. - PromTarget *promforward.Target + PromTarget *promforward.Target // PromDisabled, when true, explicitly opts this investigation out of // the prom MCP regardless of profile defaults. Set at preflight time // when the operator checks "disable prom MCP" in the form. PromDisabled bool - CreatedAt time.Time + CreatedAt time.Time // Set on imported investigations (those adopted from a teammate's // share bundle). Both fields are written together; absence means a @@ -285,47 +285,47 @@ type CloudMCP struct { // InvestigationDTO is the JSON shape returned by /api/investigations and // /api/preflight. Snapshotted under the lock so reads are race-free. type InvestigationDTO struct { - ID string `json:"id"` - Namespace string `json:"namespace"` - IncidentURL string `json:"incidentUrl,omitempty"` - SlackChannelURL string `json:"slackChannelUrl,omitempty"` - SlackChannelID string `json:"slackChannelId,omitempty"` - SlackChannelName string `json:"slackChannelName,omitempty"` - Notes string `json:"notes,omitempty"` - Label string `json:"label,omitempty"` - MCPConfigPath string `json:"mcpConfigPath"` - DocsPrefix string `json:"docsPrefix,omitempty"` - SessionDir string `json:"sessionDir"` + ID string `json:"id"` + Namespace string `json:"namespace"` + IncidentURL string `json:"incidentUrl,omitempty"` + SlackChannelURL string `json:"slackChannelUrl,omitempty"` + SlackChannelID string `json:"slackChannelId,omitempty"` + SlackChannelName string `json:"slackChannelName,omitempty"` + Notes string `json:"notes,omitempty"` + Label string `json:"label,omitempty"` + MCPConfigPath string `json:"mcpConfigPath"` + DocsPrefix string `json:"docsPrefix,omitempty"` + SessionDir string `json:"sessionDir"` SlackMCPEnabled bool `json:"slackMCPEnabled,omitempty"` IncidentioMCPEnabled bool `json:"incidentioMCPEnabled,omitempty"` LinkedRepos []repos.LinkedRepo `json:"linkedRepos,omitempty"` // CloudMCPs are the cloud-context MCP servers wired into this session, // derived from the profile's cloud sources. Empty when no cloud sources are // configured, or when the session carries no profile (e.g. an import). - CloudMCPs []CloudMCP `json:"cloudMcps,omitempty"` - CreatedAt time.Time `json:"createdAt"` - Started bool `json:"started"` - Streaming bool `json:"streaming"` - Archived bool `json:"archived"` - ImportedFrom *ImportedFrom `json:"importedFrom,omitempty"` - ImportedAt time.Time `json:"importedAt,omitempty"` - Author persistedAuthor `json:"author"` - Pushed bool `json:"pushed"` - PushedAt *time.Time `json:"pushedAt,omitempty"` - PushURL string `json:"pushUrl,omitempty"` + CloudMCPs []CloudMCP `json:"cloudMcps,omitempty"` + CreatedAt time.Time `json:"createdAt"` + Started bool `json:"started"` + Streaming bool `json:"streaming"` + Archived bool `json:"archived"` + ImportedFrom *ImportedFrom `json:"importedFrom,omitempty"` + ImportedAt time.Time `json:"importedAt,omitempty"` + Author persistedAuthor `json:"author"` + Pushed bool `json:"pushed"` + PushedAt *time.Time `json:"pushedAt,omitempty"` + PushURL string `json:"pushUrl,omitempty"` // PR lifecycle: refreshed by Manager.RefreshPRStates against // `gh pr list` on the upstream sessions repo. Lets the frontend // distinguish open vs merged vs closed and bring the push button // back when the operator closed without merging. - PRState string `json:"prState,omitempty"` - PRMergedAt *time.Time `json:"prMergedAt,omitempty"` - PRClosedAt *time.Time `json:"prClosedAt,omitempty"` - PushInProgress bool `json:"pushInProgress,omitempty"` - PushStartedAt *time.Time `json:"pushStartedAt,omitempty"` - PushError string `json:"pushError,omitempty"` - ClaudeSessionID string `json:"claudeSessionId,omitempty"` - LaunchCwd string `json:"launchCwd,omitempty"` - KubeconfigPath string `json:"kubeconfigPath,omitempty"` + PRState string `json:"prState,omitempty"` + PRMergedAt *time.Time `json:"prMergedAt,omitempty"` + PRClosedAt *time.Time `json:"prClosedAt,omitempty"` + PushInProgress bool `json:"pushInProgress,omitempty"` + PushStartedAt *time.Time `json:"pushStartedAt,omitempty"` + PushError string `json:"pushError,omitempty"` + ClaudeSessionID string `json:"claudeSessionId,omitempty"` + LaunchCwd string `json:"launchCwd,omitempty"` + KubeconfigPath string `json:"kubeconfigPath,omitempty"` // ActiveContext is the kubeconfig context the launcher pre-seeded // for the k8s MCP from the operator's cluster_id pick at preflight. ActiveContext string `json:"activeContext,omitempty"` @@ -384,49 +384,49 @@ func (i *Investigation) Snapshot() InvestigationDTO { i.mu.Lock() defer i.mu.Unlock() return InvestigationDTO{ - ID: i.ID, - Namespace: i.Namespace, - IncidentURL: i.IncidentURL, - SlackChannelURL: i.SlackChannelURL, - SlackChannelID: i.SlackChannelID, - SlackChannelName: i.SlackChannelName, - Notes: i.Notes, - Label: i.Label, - MCPConfigPath: i.MCPConfigPath, - DocsPrefix: i.DocsPrefix, - SessionDir: i.SessionDir, + ID: i.ID, + Namespace: i.Namespace, + IncidentURL: i.IncidentURL, + SlackChannelURL: i.SlackChannelURL, + SlackChannelID: i.SlackChannelID, + SlackChannelName: i.SlackChannelName, + Notes: i.Notes, + Label: i.Label, + MCPConfigPath: i.MCPConfigPath, + DocsPrefix: i.DocsPrefix, + SessionDir: i.SessionDir, SlackMCPEnabled: i.SlackMCPEnabled, IncidentioMCPEnabled: i.IncidentioMCPEnabled, LinkedRepos: i.LinkedRepos, CloudMCPs: cloudMCPsForProfile(i.Profile), - CreatedAt: i.CreatedAt, - Started: i.started, - Streaming: i.streaming, - Archived: i.archived, - ImportedFrom: i.ImportedFrom, - ImportedAt: i.ImportedAt, - Author: i.Author, - Pushed: i.PushedAt != nil, - PushedAt: i.PushedAt, - PushURL: i.PushURL, - PRState: i.PRState, - PRMergedAt: i.PRMergedAt, - PRClosedAt: i.PRClosedAt, - PushInProgress: i.PushInProgress, - PushStartedAt: i.PushStartedAt, - PushError: i.PushError, - ClaudeSessionID: i.ClaudeSessionID, - LaunchCwd: i.LaunchCwd, - KubeconfigPath: i.KubeconfigPath, - ActiveContext: i.ActiveContext, - Auto: i.Auto, - OriginatingWatchID: i.OriginatingWatchID, - OriginatingSignal: i.OriginatingSignal, - PromTarget: i.PromTarget, - PromDisabled: i.PromDisabled, - PromEnabled: i.PromTarget != nil && !i.PromDisabled, - Resumable: !i.archived && i.ClaudeSessionID != "" && i.needsRehydrate, - Slug: computeSessionSlug(i.CreatedAt, i.Namespace, i.ID), + CreatedAt: i.CreatedAt, + Started: i.started, + Streaming: i.streaming, + Archived: i.archived, + ImportedFrom: i.ImportedFrom, + ImportedAt: i.ImportedAt, + Author: i.Author, + Pushed: i.PushedAt != nil, + PushedAt: i.PushedAt, + PushURL: i.PushURL, + PRState: i.PRState, + PRMergedAt: i.PRMergedAt, + PRClosedAt: i.PRClosedAt, + PushInProgress: i.PushInProgress, + PushStartedAt: i.PushStartedAt, + PushError: i.PushError, + ClaudeSessionID: i.ClaudeSessionID, + LaunchCwd: i.LaunchCwd, + KubeconfigPath: i.KubeconfigPath, + ActiveContext: i.ActiveContext, + Auto: i.Auto, + OriginatingWatchID: i.OriginatingWatchID, + OriginatingSignal: i.OriginatingSignal, + PromTarget: i.PromTarget, + PromDisabled: i.PromDisabled, + PromEnabled: i.PromTarget != nil && !i.PromDisabled, + Resumable: !i.archived && i.ClaudeSessionID != "" && i.needsRehydrate, + Slug: computeSessionSlug(i.CreatedAt, i.Namespace, i.ID), SyncState: sessionSyncStateFor(sessionSyncStateInputs{ HasLocal: true, // we're snapshotting an in-memory Investigation, by definition local Pushed: i.PushedAt != nil, @@ -781,17 +781,23 @@ func (i *Investigation) publishRehydrateState(p RehydrateStatePayload) { // InvestigationID and EditorSessionID are empty. func streamEnvelopeFromGlobal(env GlobalEventEnvelope) StreamEnvelope { return StreamEnvelope{ - Seq: env.Seq, - Kind: env.Kind, - Timestamp: env.Timestamp, - RepoSummary: env.RepoSummary, - CodefixPRState: env.CodefixPRState, - WatchStatus: env.WatchStatus, - SignalCreated: env.SignalCreated, - ItemCaptured: env.ItemCaptured, - IngestRunStarted: env.IngestRunStarted, - IngestRunFinished: env.IngestRunFinished, - } + Seq: env.Seq, + Kind: env.Kind, + Timestamp: env.Timestamp, + RepoSummary: env.RepoSummary, + CodefixPRState: env.CodefixPRState, + WatchStatus: env.WatchStatus, + SignalCreated: env.SignalCreated, + ItemCaptured: env.ItemCaptured, + IngestRunStarted: env.IngestRunStarted, + IngestRunFinished: env.IngestRunFinished, + WikiProposalCreated: env.WikiProposalCreated, + } +} + +// PublishWikiProposalCreated fans a WikiProposalCreatedEvent out to the global stream. +func (m *Manager) PublishWikiProposalCreated(ev WikiProposalCreatedEvent) { + m.publishGlobalEvent(GlobalEventEnvelope{Kind: globalKindWikiProposalCreated, WikiProposalCreated: &ev}) } // PublishWatchStatus fans a WatchStatusEvent out to the global stream. diff --git a/internal/server/stream.go b/internal/server/stream.go index ac93795c..b6b5fdfe 100644 --- a/internal/server/stream.go +++ b/internal/server/stream.go @@ -36,22 +36,23 @@ type StreamEnvelope struct { // Per-kind payload — superset of EventEnvelope and // GlobalEventEnvelope today. - SessionID string `json:"sessionId,omitempty"` - Subtype string `json:"subtype,omitempty"` - Text string `json:"text,omitempty"` - ToolID string `json:"toolId,omitempty"` - ToolName string `json:"toolName,omitempty"` - ToolInput map[string]any `json:"toolInput,omitempty"` - ParentToolID string `json:"parentToolId,omitempty"` - PushState *PushStatePayload `json:"pushState,omitempty"` - RehydrateState *RehydrateStatePayload `json:"rehydrateState,omitempty"` - RepoSummary *RepoSummaryStatePayload `json:"repoSummary,omitempty"` - CodefixPRState *CodefixPRStatePayload `json:"codefixPRState,omitempty"` - WatchStatus *WatchStatusEvent `json:"watchStatus,omitempty"` - SignalCreated *SignalCreatedEvent `json:"signalCreated,omitempty"` - ItemCaptured *ItemCapturedEvent `json:"itemCaptured,omitempty"` - IngestRunStarted *IngestRunStartedEvent `json:"ingestRunStarted,omitempty"` - IngestRunFinished *IngestRunFinishedEvent `json:"ingestRunFinished,omitempty"` + SessionID string `json:"sessionId,omitempty"` + Subtype string `json:"subtype,omitempty"` + Text string `json:"text,omitempty"` + ToolID string `json:"toolId,omitempty"` + ToolName string `json:"toolName,omitempty"` + ToolInput map[string]any `json:"toolInput,omitempty"` + ParentToolID string `json:"parentToolId,omitempty"` + PushState *PushStatePayload `json:"pushState,omitempty"` + RehydrateState *RehydrateStatePayload `json:"rehydrateState,omitempty"` + RepoSummary *RepoSummaryStatePayload `json:"repoSummary,omitempty"` + CodefixPRState *CodefixPRStatePayload `json:"codefixPRState,omitempty"` + WatchStatus *WatchStatusEvent `json:"watchStatus,omitempty"` + SignalCreated *SignalCreatedEvent `json:"signalCreated,omitempty"` + ItemCaptured *ItemCapturedEvent `json:"itemCaptured,omitempty"` + IngestRunStarted *IngestRunStartedEvent `json:"ingestRunStarted,omitempty"` + IngestRunFinished *IngestRunFinishedEvent `json:"ingestRunFinished,omitempty"` + WikiProposalCreated *WikiProposalCreatedEvent `json:"wikiProposalCreated,omitempty"` // Usage rides on assistant + result envelopes (claude per-message / // per-CLI-invocation token tally). Subscribers that fold these into // running session totals read off result envelopes only. diff --git a/internal/sessions/session.go b/internal/sessions/session.go index 9aced7aa..8d9e5925 100644 --- a/internal/sessions/session.go +++ b/internal/sessions/session.go @@ -99,9 +99,27 @@ func (o Options) alertPayloadFields() map[string]string { // codefix) the agent rationalises why it isn't creating a todo list and // leaks "Walker continues to track progress; skipping TodoWrite…" into // the operator's chat. This addendum pre-empts that rationalisation. -const systemPromptAddendum = `The triagent-strategies walker is your task tracker. You do not have TodoWrite or any other Claude Code planning tool — they are not in your --allowedTools whitelist. Do not reference them in your replies. When a step's terminal_advice lists follow-on steps (e.g. "run wiki, then playbook, then codefix"), execute them in order without announcing the decision to skip a planning tool. +const systemPromptAddendum = `The triagent-strategies walker is your task tracker. You do not have TodoWrite or any other Claude Code planning tool — they are not in your --allowedTools whitelist. Do not reference them in your replies. When a step's terminal_advice lists follow-on steps (e.g. "run wiki, then playbook, then codefix"), execute them in order without announcing the decision to skip a planning tool.` -Kubernetes auth: triagent-k8s tools require an active context. Before your FIRST triagent-k8s.* call this session — including list_resource_kinds, get_resource, list_resources, get_logs, list_events — establish a context: call triagent-teleport.list_clusters (filter by the alert's cluster name), pick the matching entry, then call triagent-teleport.login if CurrentlyLoggedIn is false, and finally triagent-k8s.switch_context with the KubeContext value the list_clusters response gave you. Do this proactively; do not let the first k8s call fail and then react. After that initial setup any triagent-k8s call works until you switch_context to another cluster.` +// k8sAuthTeleportGuidance steers a Teleport deployment: clusters are +// provisioned on demand via the triagent-teleport MCP, which is only wired +// when the profile's auth.kind is "teleport". +const k8sAuthTeleportGuidance = `Kubernetes auth: triagent-k8s tools require an active context. Before your FIRST triagent-k8s.* call this session — including list_resource_kinds, get_resource, list_resources, get_logs, list_events — establish a context: call triagent-teleport.list_clusters (filter by the alert's cluster name), pick the matching entry, then call triagent-teleport.login if CurrentlyLoggedIn is false, and finally triagent-k8s.switch_context with the KubeContext value the list_clusters response gave you. Do this proactively; do not let the first k8s call fail and then react. After that initial setup any triagent-k8s call works until you switch_context to another cluster.` + +// k8sAuthKubeconfigGuidance steers a kubeconfig deployment: the contexts +// already live in the kubeconfig, so there is no Teleport step — naming the +// Teleport MCP here would send the agent after tools that aren't wired. +const k8sAuthKubeconfigGuidance = `Kubernetes auth: triagent-k8s tools require an active context. Before your FIRST triagent-k8s.* call this session — including list_resource_kinds, get_resource, list_resources, get_logs, list_events — bind one: call triagent-k8s.list_contexts, pick the context matching the alert's cluster, and call triagent-k8s.switch_context with its name. Do this proactively; do not let the first k8s call fail and then react. After that initial setup any triagent-k8s call works until you switch_context to another cluster.` + +// k8sAuthGuidance returns the auth recipe that matches the deployment's +// auth.kind. Anything that is not an explicit teleport profile gets the +// kubeconfig recipe, since that is the path whose tools are wired. +func k8sAuthGuidance(prof *profile.Profile) string { + if prof != nil && prof.Auth.Kind == "teleport" { + return k8sAuthTeleportGuidance + } + return k8sAuthKubeconfigGuidance +} // renderSystemPromptAddendum appends namespace hints from the profile to the // base addendum. Empty / no-match cases return the base unchanged. The @@ -134,7 +152,7 @@ func New(opts Options) (*Session, error) { claude.SessionOpts{ Cwd: opts.LaunchCwd, Env: opts.childEnv(), - AppendSystemPrompt: renderSystemPromptAddendum(systemPromptAddendum, namespaceCfg, opts.alertPayloadFields()), + AppendSystemPrompt: renderSystemPromptAddendum(systemPromptAddendum+"\n\n"+k8sAuthGuidance(opts.Profile), namespaceCfg, opts.alertPayloadFields()), Model: investigationModel, }, ) @@ -205,14 +223,18 @@ func allowedTools(prof *profile.Profile, linkedRepos []repos.LinkedRepo, slackAv "mcp__" + preflight.MCPAliasStrategies + "__*", "mcp__" + preflight.MCPAliasMeta + "__*", "mcp__" + preflight.MCPAliasParallel + "__*", - // triagent-teleport is wired unconditionally by mcpconfig.go; // triagent-wiki is wired whenever WikiPath/WikiProposalsPath are - // set, which is the standard launcher configuration. Including - // both here silences the per-call approval prompts. Tools from - // non-wired MCPs simply won't be reachable at execution time. - "mcp__" + preflight.MCPAliasTeleport + "__*", + // set, which is the standard launcher configuration. Including it + // here silences the per-call approval prompts. Tools from non-wired + // MCPs simply won't be reachable at execution time. "mcp__" + preflight.MCPAliasWiki + "__*", } + // triagent-teleport is wired only for a teleport deployment (mcpconfig.go + // gates it on auth.kind); allowlist it on the same condition so a + // kubeconfig deployment doesn't carry a glob for tools that never exist. + if prof != nil && prof.Auth.Kind == "teleport" { + out = append(out, "mcp__"+preflight.MCPAliasTeleport+"__*") + } if prof != nil { for _, m := range prof.ExtraMCPs { if len(m.AllowedTools) > 0 { diff --git a/internal/sessions/session_test.go b/internal/sessions/session_test.go index 2f9fbfdc..7fab1f2b 100644 --- a/internal/sessions/session_test.go +++ b/internal/sessions/session_test.go @@ -29,13 +29,26 @@ func TestAllowedTools_AlwaysIncludesCoreServers(t *testing.T) { preflight.MCPAliasStrategies, preflight.MCPAliasMeta, preflight.MCPAliasParallel, - preflight.MCPAliasTeleport, preflight.MCPAliasWiki, } { require.Contains(t, got, "mcp__"+alias+"__*", "missing core server alias %s", alias) } } +func TestAllowedTools_TeleportGatedOnAuthKind(t *testing.T) { + t.Parallel() + teleportGlob := "mcp__" + preflight.MCPAliasTeleport + "__*" + // mcpconfig.go only wires the teleport MCP for a teleport deployment; + // the allowlist must match so a kubeconfig deployment carries no glob + // for tools that never exist. + require.Contains(t, + allowedTools(&profile.Profile{Auth: profile.Auth{Kind: "teleport"}}, nil, false, false), + teleportGlob, "teleport deployment should allowlist the teleport MCP") + require.NotContains(t, + allowedTools(&profile.Profile{Auth: profile.Auth{Kind: "kubeconfig"}}, nil, false, false), + teleportGlob, "kubeconfig deployment must not allowlist the teleport MCP") +} + func TestAllowedTools_IncludesExtraMCPs(t *testing.T) { t.Parallel() prof := &profile.Profile{ @@ -61,20 +74,40 @@ func TestRenderSystemPromptAddendum_NoProfileHintAppendsNothingExtra(t *testing. assert.Equal(t, systemPromptAddendum, got) } -func TestSystemPromptAddendum_NamesTeleportAuthSequence(t *testing.T) { +func TestK8sAuthGuidance_TeleportDeploymentNamesTeleportSequence(t *testing.T) { t.Parallel() // Front-loading the auth sequence into the system prompt steers the // agent to do it proactively instead of failing the first k8s call - // and reacting. Verify the three tool names are spelled exactly so a - // refactor of any one of them surfaces the hint as out-of-date. + // and reacting. Verify the tool names are spelled exactly so a refactor + // of any one of them surfaces the hint as out-of-date. + got := k8sAuthGuidance(&profile.Profile{Auth: profile.Auth{Kind: "teleport"}}) for _, want := range []string{ "triagent-teleport.list_clusters", "triagent-teleport.login", "triagent-k8s.switch_context", "KubeContext", } { - assert.Contains(t, systemPromptAddendum, want, - "addendum should mention %q so the agent knows the auth recipe up front", want) + assert.Contains(t, got, want, + "teleport-deployment guidance should mention %q so the agent knows the auth recipe up front", want) + } +} + +func TestK8sAuthGuidance_KubeconfigDeploymentNamesContextSequenceNotTeleport(t *testing.T) { + t.Parallel() + // A kubeconfig deployment has no Teleport MCP — the contexts already + // live in the kubeconfig. Steering the agent at list_clusters/login + // there sends it after tools that don't exist (the original bug). + for _, prof := range []*profile.Profile{ + {Auth: profile.Auth{Kind: "kubeconfig"}}, + nil, + } { + got := k8sAuthGuidance(prof) + assert.Contains(t, got, "triagent-k8s.list_contexts", + "kubeconfig guidance should steer at list_contexts") + assert.Contains(t, got, "triagent-k8s.switch_context", + "kubeconfig guidance should steer at switch_context") + assert.NotContains(t, got, "triagent-teleport", + "kubeconfig guidance must not mention the Teleport MCP") } } diff --git a/pkg/mcp/k8s/server.go b/pkg/mcp/k8s/server.go index db7072d7..56edbefb 100644 --- a/pkg/mcp/k8s/server.go +++ b/pkg/mcp/k8s/server.go @@ -110,7 +110,7 @@ func (s *Server) Run(ctx context.Context) error { // noActiveContextMessage is the uniform error every existing tool returns // when called before the first successful switch_context. -const noActiveContextMessage = "no active kubernetes context — call triagent-teleport.list_clusters → triagent-teleport.login → triagent-k8s.switch_context first" +const noActiveContextMessage = "no active kubernetes context — bind one with triagent-k8s.list_contexts then triagent-k8s.switch_context. For a Teleport-managed cluster, provision the context first via triagent-teleport.list_clusters → triagent-teleport.login." // buildSnapshot constructs a fresh clientSnapshot for contextName. Returns // the snapshot or an error; callers swap it onto the ToolKit only on diff --git a/pkg/mcp/k8s/tools_contexts.go b/pkg/mcp/k8s/tools_contexts.go index f71ca17e..8807cb4c 100644 --- a/pkg/mcp/k8s/tools_contexts.go +++ b/pkg/mcp/k8s/tools_contexts.go @@ -92,7 +92,7 @@ func (t *ToolKit) getCurrentContext(_ context.Context, _ *mcp.CallToolRequest, _ // --- switch_context -------------------------------------------------------- type SwitchContextInput struct { - Name string `json:"name" jsonschema:"Kubeconfig context to bind to. Use triagent-teleport.login to provision a context for a Teleport-managed cluster first."` + Name string `json:"name" jsonschema:"Kubeconfig context to bind to — one of the names from list_contexts. For a Teleport-managed cluster, run triagent-teleport.login first to provision the context."` } type SwitchContextOutput struct { diff --git a/pkg/mcp/k8s/tools_resources_test.go b/pkg/mcp/k8s/tools_resources_test.go index c2133339..6cac7d00 100644 --- a/pkg/mcp/k8s/tools_resources_test.go +++ b/pkg/mcp/k8s/tools_resources_test.go @@ -264,7 +264,9 @@ func TestListResourceKinds_NoActiveContext_ReturnsHelpfulError(t *testing.T) { require.NotEmpty(t, res.Content) tc := res.Content[0].(*mcp.TextContent) assert.Contains(t, tc.Text, "no active kubernetes context") - assert.Contains(t, tc.Text, "triagent-teleport.list_clusters") + // Lead with the k8s-native path so a kubeconfig deployment (no Teleport + // MCP) is steered at tools that actually exist. + assert.Contains(t, tc.Text, "triagent-k8s.list_contexts") assert.Contains(t, tc.Text, "triagent-k8s.switch_context") } diff --git a/pkg/mcp/strategies/dispatch.go b/pkg/mcp/strategies/dispatch.go index 08b74396..f15a90bf 100644 --- a/pkg/mcp/strategies/dispatch.go +++ b/pkg/mcp/strategies/dispatch.go @@ -15,9 +15,9 @@ import ( func dispatchAllowedToolsFor(playbookID string) string { switch playbookID { case "wiki_proposal": - return "mcp__triagent-wiki__propose_wiki_draft,mcp__triagent-wiki__wiki_get,mcp__triagent-wiki__wiki_list_entities,mcp__triagent-wiki__wiki_search" + return "mcp__triagent-wiki__propose_wiki_draft,mcp__triagent-strategies__decline_proposal,mcp__triagent-wiki__wiki_get,mcp__triagent-wiki__wiki_list_entities,mcp__triagent-wiki__wiki_search" case "playbook_proposal": - return "mcp__triagent-strategies__playbook_proposal_draft,mcp__triagent-strategies__list_playbooks,mcp__triagent-strategies__list_proposals,mcp__triagent-strategies__get_playbook_raw,mcp__triagent-strategies__playbook_correlate,mcp__triagent-strategies__validate_playbook" + return "mcp__triagent-strategies__playbook_proposal_draft,mcp__triagent-strategies__decline_proposal,mcp__triagent-strategies__list_playbooks,mcp__triagent-strategies__list_proposals,mcp__triagent-strategies__get_playbook_raw,mcp__triagent-strategies__playbook_correlate,mcp__triagent-strategies__validate_playbook" default: // Unknown dispatch-mode playbooks get no MCP tools beyond what // claude's built-ins provide. Operator can extend the table when @@ -40,15 +40,89 @@ func dispatchAllowedToolsFor(playbookID string) string { func dispatchTimeoutFor(playbookID string) time.Duration { switch playbookID { case "playbook_proposal", "wiki_proposal": - return 10 * time.Minute + return 15 * time.Minute default: return 0 } } -// runDispatch executes a dispatch-mode playbook as one sub-agent run and -// returns a Result the caller can stuff into walkPlaybookOut.Dispatched. -func (s *Server) runDispatch(ctx context.Context, pb *Playbook, parentSessionID, notes, operatorRefinement string) (subagent.Result, error) { +// dispatchProposalToolFor returns the tool a proposal flow MUST call to +// actually submit its draft, or "" for non-proposal dispatches. Drives the +// mandatory finishing instruction in the dispatch prompt and (paired) the +// subagent terminal-tool verification — the agent invokes it by its bare +// name, so that's what the prompt names. +func dispatchProposalToolFor(playbookID string) string { + switch playbookID { + case "playbook_proposal": + return "playbook_proposal_draft" + case "wiki_proposal": + return "propose_wiki_draft" + default: + return "" + } +} + +// dispatchValidateToolFor returns the validator a proposal flow must run +// before submitting, or "" when the submit tool is the only validator (wiki). +// Drives the validate-before-submit step in the dispatch prompt. +func dispatchValidateToolFor(playbookID string) string { + switch playbookID { + case "playbook_proposal": + return "validate_playbook" + default: + return "" + } +} + +// maxForceDispatchRetries bounds how many times runDispatch resumes a +// proposal sub-agent that ended without reaching a terminal, forcing it to +// call playbook_proposal_draft / propose_wiki_draft or decline_proposal. +const maxForceDispatchRetries = 2 + +// dispatchTerminalToolsFor returns the wire tool names that count as a valid +// terminal for a proposal flow — submit first, decline second — or nil for a +// non-proposal dispatch (no verification). The order is load-bearing: +// classifyProposalOutcome reads index 0 as the submit tool. +func dispatchTerminalToolsFor(playbookID string) []string { + switch playbookID { + case "playbook_proposal": + return []string{"mcp__triagent-strategies__playbook_proposal_draft", "mcp__triagent-strategies__decline_proposal"} + case "wiki_proposal": + return []string{"mcp__triagent-wiki__propose_wiki_draft", "mcp__triagent-strategies__decline_proposal"} + default: + return nil + } +} + +// classifyProposalOutcome maps the terminals the sub-agent actually called to +// submitted | declined | none. terminals is [submit, decline] from +// dispatchTerminalToolsFor. +func classifyProposalOutcome(terminals, called []string) string { + calledSet := make(map[string]struct{}, len(called)) + for _, c := range called { + calledSet[c] = struct{}{} + } + if _, ok := calledSet[terminals[0]]; ok { + return "submitted" + } + if _, ok := calledSet[terminals[1]]; ok { + return "declined" + } + return "none" +} + +// forceTerminalPrompt is the follow-up sent when a proposal sub-agent ends +// without a terminal — a short, unambiguous instruction to finish properly. +func forceTerminalPrompt(playbookID string) string { + return fmt.Sprintf("You ended your last turn without reaching a terminal. You MUST now call either `%s` to submit the draft you prepared, or `decline_proposal` with a one-line reason if you are deliberately not proposing. Call the tool now — do not reply with prose.", dispatchProposalToolFor(playbookID)) +} + +// runDispatch executes a dispatch-mode playbook as a sub-agent run. For a +// proposal flow it verifies a terminal tool actually fired and, if not, +// resumes the same conversation to force one (bounded by +// maxForceDispatchRetries). Returns the final Result, the classified proposal +// outcome ("" for non-proposal dispatches), and any error. +func (s *Server) runDispatch(ctx context.Context, pb *Playbook, parentSessionID, notes, operatorRefinement string) (subagent.Result, string, error) { var ( findings map[string]any summary string @@ -71,16 +145,41 @@ func (s *Server) runDispatch(ctx context.Context, pb *Playbook, parentSessionID, Summary: summary, OperatorRefinement: operatorRefinement, Proposals: proposals, + ProposalTool: dispatchProposalToolFor(pb.ID), + ValidateTool: dispatchValidateToolFor(pb.ID), }) if s.subAgentRunner == nil { - return subagent.Result{}, fmt.Errorf("dispatch %q: subagent runner not configured", pb.ID) + return subagent.Result{}, "", fmt.Errorf("dispatch %q: subagent runner not configured", pb.ID) } - return s.subAgentRunner(ctx, subagent.Options{ - AllowedTools: dispatchAllowedToolsFor(pb.ID), - Prompt: prompt, - Model: s.models.Subagent, - MCPConfigPath: s.mcpConfigPath, - ParentToolID: telemetry.CurrentToolID(ctx), - Timeout: dispatchTimeoutFor(pb.ID), - }) + terminals := dispatchTerminalToolsFor(pb.ID) + baseOpts := subagent.Options{ + AllowedTools: dispatchAllowedToolsFor(pb.ID), + Prompt: prompt, + Model: s.models.Subagent, + MCPConfigPath: s.mcpConfigPath, + ParentToolID: telemetry.CurrentToolID(ctx), + Timeout: dispatchTimeoutFor(pb.ID), + RequiredTerminalTools: terminals, + } + res, err := s.subAgentRunner(ctx, baseOpts) + if err != nil { + return res, "", err + } + // Non-proposal dispatches aren't verified — preserve prior behaviour. + if len(terminals) == 0 { + return res, "", nil + } + // Resume-and-force when the sub-agent ended without a terminal. A + // timed-out run is surfaced as-is, not retried (the cap fired mid-work). + forcing := forceTerminalPrompt(pb.ID) + for attempt := 0; attempt < maxForceDispatchRetries && len(res.TerminalToolsCalled) == 0 && !res.TimedOut; attempt++ { + retryOpts := baseOpts + retryOpts.Prompt = forcing + retryOpts.ResumeSessionID = res.SessionID + res, err = s.subAgentRunner(ctx, retryOpts) + if err != nil { + return res, "", err + } + } + return res, classifyProposalOutcome(terminals, res.TerminalToolsCalled), nil } diff --git a/pkg/mcp/strategies/dispatch_prompt.go b/pkg/mcp/strategies/dispatch_prompt.go index 133736d1..aebe82c0 100644 --- a/pkg/mcp/strategies/dispatch_prompt.go +++ b/pkg/mcp/strategies/dispatch_prompt.go @@ -21,6 +21,20 @@ type DispatchInputs struct { // the operator's note) without depending on the master agent to have // remembered to forward them in Notes. Empty → section omitted. Proposals []ProposalSummary + // ProposalTool is the tool this flow MUST call to actually submit its + // draft (e.g. playbook_proposal_draft / propose_wiki_draft). When set, + // BuildDispatchPrompt appends a mandatory finishing instruction: the + // sub-agent must end by calling it (or decline_proposal), never with a + // prose summary or a file write. Empty → no finishing section (non- + // proposal dispatches). Pairs with subagent terminal-tool verification. + ProposalTool string + // ValidateTool is the tool that validates a draft before submission + // (e.g. validate_playbook). When set, the finishing instruction tells + // the sub-agent to validate-until-clean before calling ProposalTool — + // the submit tool rejects invalid input, so an unvalidated draft is + // wasted effort. Empty → no validation step (flows whose submit tool is + // the only validator, e.g. wiki). + ValidateTool string } // BuildDispatchPrompt assembles the single-turn prompt the sub-agent receives. @@ -77,6 +91,32 @@ func BuildDispatchPrompt(in DispatchInputs) string { b.WriteString(strings.TrimSpace(in.OperatorRefinement)) b.WriteString("\n\n") } + if tool := strings.TrimSpace(in.ProposalTool); tool != "" { + var submit strings.Builder + if validate := strings.TrimSpace(in.ValidateTool); validate != "" { + fmt.Fprintf(&submit, + "1. Draft the artifact from the playbook nodes above and the context in this prompt.\n"+ + "2. Validate before you submit: call `%s` on your draft and fix EVERY error it reports; repeat until it returns no errors. `%s` rejects invalid input — it returns `validation_errors` and an empty `proposal_id`, which produces nothing the operator can act on — so never submit a draft you have not validated clean.\n"+ + "3. Only after validation is clean, call `%s` with the draft and confirm it returns a non-empty `proposal_id`. That returned id is the only signal the operator sees.\n", + validate, tool, tool) + } else { + fmt.Fprintf(&submit, + "1. Draft the artifact from the playbook nodes above and the context in this prompt.\n"+ + "2. Call `%s` with your draft and confirm it returns a non-empty `proposal_id`. That returned id is the only signal the operator sees.\n", + tool) + } + fmt.Fprintf(&b, `## Finishing — required + +Your result MUST be a real tool call. A prose summary describing what you would submit, or writing the draft to a file, does NOT count and the operator will never see it. + +**To submit:** + +%s +**To deliberately not propose** (the work is below the bar): call `+"`decline_proposal`"+` with a one-line reason. + +Do not end the task any other way. If you are about to write a final summary without having called one of these tools, stop and call the tool first. +`, submit.String()) + } return b.String() } diff --git a/pkg/mcp/strategies/dispatch_prompt_test.go b/pkg/mcp/strategies/dispatch_prompt_test.go index c6d9873d..3a431496 100644 --- a/pkg/mcp/strategies/dispatch_prompt_test.go +++ b/pkg/mcp/strategies/dispatch_prompt_test.go @@ -29,6 +29,64 @@ func TestBuildDispatchPrompt_IncludesPlaybookNodesInOrder(t *testing.T) { assert.True(t, strings.Contains(prompt, "the summary")) } +func TestBuildDispatchPrompt_NamesTerminalToolWhenSet(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "playbook_proposal", Entrypoint: "a", Nodes: map[string]Node{ + "a": {ID: "a", Description: "draft a playbook"}, + }} + prompt := BuildDispatchPrompt(DispatchInputs{ + Playbook: pb, + ProposalTool: "playbook_proposal_draft", + }) + // The terminal instruction must name the submit tool and forbid ending + // with only a prose summary or a file write — the exact divergence that + // produced a confabulated "drafted" with no actual proposal. + assert.Contains(t, prompt, "playbook_proposal_draft", + "finishing instruction must name the proposal tool the flow has to call") + low := strings.ToLower(prompt) + assert.Contains(t, low, "summary", "must warn that a prose summary is not a submission") + assert.Contains(t, low, "must", "finishing instruction is mandatory, not advisory") +} + +func TestBuildDispatchPrompt_RequiresValidationBeforeSubmit(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "playbook_proposal", Entrypoint: "a", Nodes: map[string]Node{ + "a": {ID: "a", Description: "draft a playbook"}, + }} + prompt := BuildDispatchPrompt(DispatchInputs{ + Playbook: pb, + ProposalTool: "playbook_proposal_draft", + ValidateTool: "validate_playbook", + }) + // The agent must be told to validate (the real session submitted invalid + // YAML five times before one validated). The instruction must name the + // validator and tie it to submitting. + assert.Contains(t, prompt, "validate_playbook", + "finishing instruction must name the validator the flow has to run before submitting") + assert.Contains(t, strings.ToLower(prompt), "before", + "validation must be ordered before the submit") +} + +func TestBuildDispatchPrompt_NoValidateLineWhenValidateToolUnset(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "wiki_proposal", Entrypoint: "a", Nodes: map[string]Node{ + "a": {ID: "a", Description: "draft a wiki entry"}, + }} + // The wiki flow has a submit tool but no separate validator. + prompt := BuildDispatchPrompt(DispatchInputs{Playbook: pb, ProposalTool: "propose_wiki_draft"}) + assert.NotContains(t, prompt, "validate_playbook") +} + +func TestBuildDispatchPrompt_NoTerminalSectionWhenToolUnset(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "pb", Entrypoint: "a", Nodes: map[string]Node{ + "a": {ID: "a", Description: "step"}, + }} + prompt := BuildDispatchPrompt(DispatchInputs{Playbook: pb}) + assert.NotContains(t, prompt, "## Finishing", + "non-proposal dispatches get no mandatory-terminal section") +} + func TestBuildDispatchPrompt_AppendsRefinementWhenPresent(t *testing.T) { t.Parallel() pb := &Playbook{ID: "pb", Entrypoint: "a", Nodes: map[string]Node{ diff --git a/pkg/mcp/strategies/dispatch_test.go b/pkg/mcp/strategies/dispatch_test.go index dc88b4ad..740f3588 100644 --- a/pkg/mcp/strategies/dispatch_test.go +++ b/pkg/mcp/strategies/dispatch_test.go @@ -248,3 +248,93 @@ func TestWalkPlaybook_DefaultDispatchUsesWalker(t *testing.T) { assert.NotEmpty(t, out.SessionID, "default dispatch returns a walker session id") assert.Equal(t, "a", out.Step.NodeID) } + +// TestWalkPlaybook_DispatchResumesAndForcesTerminal: a proposal dispatch that +// returns without calling a terminal tool is resumed (same session) with a +// forcing follow-up; once it calls the submit tool the outcome is "submitted". +func TestWalkPlaybook_DispatchResumesAndForcesTerminal(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "playbook_proposal", Dispatch: DispatchSubagent, Entrypoint: "a", + Nodes: map[string]Node{"a": {ID: "a", Description: "draft a playbook"}}} + var calls []subagent.Options + srv := newEmptyServer(t) + srv.playbooks[pb.ID] = pb + srv.subAgentRunner = func(_ context.Context, opts subagent.Options) (subagent.Result, error) { + calls = append(calls, opts) + if len(calls) == 1 { + return subagent.Result{Summary: "I wrote the YAML to a file.", SessionID: "sess-1"}, nil + } + return subagent.Result{Summary: "Submitted.", SessionID: "sess-1", + TerminalToolsCalled: []string{"mcp__triagent-strategies__playbook_proposal_draft"}}, nil + } + + _, out, err := srv.walkPlaybook(context.Background(), nil, walkPlaybookIn{PlaybookID: pb.ID}) + require.NoError(t, err) + require.Len(t, calls, 2, "a no-terminal run must be resumed and forced once") + assert.Equal(t, "sess-1", calls[1].ResumeSessionID, "the force-retry resumes the same conversation") + assert.NotEmpty(t, calls[1].Prompt, "the force-retry carries a forcing follow-up prompt") + assert.Contains(t, calls[0].RequiredTerminalTools, "mcp__triagent-strategies__playbook_proposal_draft") + require.NotNil(t, out.Dispatched) + assert.Equal(t, "submitted", out.Dispatched.ProposalOutcome) +} + +// TestWalkPlaybook_DispatchDeclineIsNotForced: a sub-agent that explicitly +// calls decline_proposal reached a terminal — no retry, outcome "declined". +func TestWalkPlaybook_DispatchDeclineIsNotForced(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "wiki_proposal", Dispatch: DispatchSubagent, Entrypoint: "a", + Nodes: map[string]Node{"a": {ID: "a", Description: "draft a wiki entry"}}} + var calls int + srv := newEmptyServer(t) + srv.playbooks[pb.ID] = pb + srv.subAgentRunner = func(_ context.Context, _ subagent.Options) (subagent.Result, error) { + calls++ + return subagent.Result{Summary: "Below the bar.", SessionID: "s", + TerminalToolsCalled: []string{"mcp__triagent-strategies__decline_proposal"}}, nil + } + _, out, err := srv.walkPlaybook(context.Background(), nil, walkPlaybookIn{PlaybookID: pb.ID}) + require.NoError(t, err) + assert.Equal(t, 1, calls, "a decline is a valid terminal — no force-retry") + assert.Equal(t, "declined", out.Dispatched.ProposalOutcome) +} + +// TestWalkPlaybook_DispatchNoTerminalSurfacesNone: when no terminal ever fires, +// the outcome is "none" and the summary is prefixed so the master can't read it +// as success. +func TestWalkPlaybook_DispatchNoTerminalSurfacesNone(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "playbook_proposal", Dispatch: DispatchSubagent, Entrypoint: "a", + Nodes: map[string]Node{"a": {ID: "a", Description: "draft"}}} + var calls int + srv := newEmptyServer(t) + srv.playbooks[pb.ID] = pb + srv.subAgentRunner = func(_ context.Context, _ subagent.Options) (subagent.Result, error) { + calls++ + return subagent.Result{Summary: "Here is what I would propose.", SessionID: "s"}, nil + } + _, out, err := srv.walkPlaybook(context.Background(), nil, walkPlaybookIn{PlaybookID: pb.ID}) + require.NoError(t, err) + assert.Equal(t, 1+maxForceDispatchRetries, calls, "initial run plus the bounded force-retries") + assert.Equal(t, "none", out.Dispatched.ProposalOutcome) + assert.Contains(t, out.Dispatched.Summary, "NO PROPOSAL WAS SUBMITTED") +} + +// TestWalkPlaybook_DispatchTimeoutIsNotForced: a timed-out run is surfaced, not +// retried (the cap fired mid-work; retrying compounds it). +func TestWalkPlaybook_DispatchTimeoutIsNotForced(t *testing.T) { + t.Parallel() + pb := &Playbook{ID: "playbook_proposal", Dispatch: DispatchSubagent, Entrypoint: "a", + Nodes: map[string]Node{"a": {ID: "a", Description: "draft"}}} + var calls int + srv := newEmptyServer(t) + srv.playbooks[pb.ID] = pb + srv.subAgentRunner = func(_ context.Context, _ subagent.Options) (subagent.Result, error) { + calls++ + return subagent.Result{Summary: "partial", SessionID: "s", TimedOut: true}, nil + } + _, out, err := srv.walkPlaybook(context.Background(), nil, walkPlaybookIn{PlaybookID: pb.ID}) + require.NoError(t, err) + assert.Equal(t, 1, calls, "a timed-out run is not force-retried") + assert.Equal(t, "none", out.Dispatched.ProposalOutcome) + assert.True(t, out.Dispatched.TimedOut) +} diff --git a/pkg/mcp/strategies/server.go b/pkg/mcp/strategies/server.go index 51d376dd..eeb92036 100644 --- a/pkg/mcp/strategies/server.go +++ b/pkg/mcp/strategies/server.go @@ -32,6 +32,7 @@ import ( // - UserPlaybooksDir is an optional directory the launcher writes // operator-customised playbooks to; entries there override or // extend the plugin set (but cannot touch the system set). +// // DispatchModels is the dispatch-relevant subset of profile.Models, threaded // in by the launcher. Defined here (not imported from internal/profile) to // keep the MCP package layering clean. @@ -192,6 +193,11 @@ func (s *Server) register() { Description: "Submit a draft playbook for the operator's inline review. Writes the YAML to a draft store; the launcher's chat UI renders a diff card vs the currently-loaded version, and the operator approves or declines via that card. NO chat-side confirmation is required before this call — the agent should call it as soon as it has a candidate. On approve, the launcher writes the proposal body to //.yaml (overwriting any existing user file) and records a git commit in the user dir's repo with the operator's chosen message (or an auto-generated one). The version field is not stamped — git history is the version axis. Returns the proposal_id (operator-facing UI uses it) and base_yaml/new_yaml (for the diff view).", }, telemetry.Wrap("playbook_proposal_draft", s.proposePlaybookDraft)) + mcp.AddTool(s.impl, &mcp.Tool{ + Name: "decline_proposal", + Description: "The explicit terminal for a proposal flow that decides NOT to submit a draft (the work is below the bar). Call this with a one-line reason instead of ending with a prose summary — the dispatcher requires either playbook_proposal_draft / propose_wiki_draft (submit) or this (decline) to fire, so it can tell a deliberate no-proposal from a sub-agent that quit without finishing. Records the reason and returns acknowledged.", + }, telemetry.Wrap("decline_proposal", s.declineProposal)) + mcp.AddTool(s.impl, &mcp.Tool{ Name: "playbook_resolve_entities", Description: "Canonicalise a candidate keyword set (services / errors / symptoms) against the union of all loaded playbooks' entity tags. **Call this BEFORE playbook_correlate** when you have specific keywords you want to map to canonical names — pass fuzzy guesses ('Zeebe Broker', 'crash looping', 'failing reconciliations') and the tool returns one Resolution per input telling you whether it was exact-match and which canonical names are close by edit distance / substring.\n\nUnlike playbook_correlate this tool does NOT validate input shape — pass fuzzy guesses as you have them. Returns one Resolution per input keyword: {field, input, exact, near}. Use `near[0]` as the canonical name to feed into playbook_correlate.\n\nLighter than dumping every loaded playbook's tags (which is what playbook_correlate's `resolution` field does as a side-effect on every call) — discrete canonicalize-then-correlate flow keeps the audit trail legible.", @@ -243,7 +249,7 @@ type walkPlaybookIn struct { PlaybookID string `json:"playbook_id" jsonschema:"the playbook to start; one of the ids returned by list_playbooks"` ClusterID string `json:"cluster_id" jsonschema:"the cluster id under investigation"` Namespace string `json:"namespace" jsonschema:"the Kubernetes namespace bound to the k8s MCP for this session"` - Notes string `json:"notes,omitempty" jsonschema:"optional operator-supplied context (incident summary, what was tried already)"` + Notes string `json:"notes,omitempty" jsonschema:"Context for this walk. For a dispatch-mode proposal playbook (wiki_proposal, playbook_proposal) this is CRITICAL and must be exhaustive: the dispatched sub-agent runs in a SEPARATE session with NO access to this investigation — your notes are its ENTIRE context. Write a complete, self-contained brief — the symptom, the key findings and evidence, the root cause and resolution, and the actual content that should end up in the artifact (entry sections and prose for a wiki entry; node descriptions, suggested_calls, and terminals for a playbook). Do not assume the sub-agent can see anything you have seen or summarised; if a detail belongs in the playbook/wiki, write it out here in full. For a non-dispatch playbook, a short incident summary plus what was tried is enough."` // ParentSessionID is set when this walk_playbook is the // follow-up to a terminal node's handoff. The walker uses it to // detect circular handoffs (A → B → A) and reject them. Always @@ -268,6 +274,12 @@ type DispatchedResult struct { TimedOut bool `json:"timed_out,omitempty"` ExitCode int `json:"exit_code,omitempty"` StderrTail string `json:"stderr_tail,omitempty"` + // ProposalOutcome is the verified terminal a proposal dispatch reached: + // "submitted" (called *_proposal_draft), "declined" (called + // decline_proposal), or "none" (ended without a terminal even after the + // force-retries). Empty for non-proposal dispatches. The master reads + // this so a missing proposal cannot be confabulated as a success. + ProposalOutcome string `json:"proposal_outcome,omitempty"` } type walkPlaybookOut struct { @@ -283,15 +295,22 @@ func (s *Server) walkPlaybook(ctx context.Context, req *mcp.CallToolRequest, in return errorResult(fmt.Sprintf("unknown playbook_id %q; call list_playbooks for valid ids", in.PlaybookID)), walkPlaybookOut{}, nil } if pb.Dispatch == DispatchSubagent { - res, err := s.runDispatch(ctx, pb, in.ParentSessionID, in.Notes, in.OperatorRefinement) + res, outcome, err := s.runDispatch(ctx, pb, in.ParentSessionID, in.Notes, in.OperatorRefinement) if err != nil { return errorResult(fmt.Sprintf("dispatch %q: %v", pb.ID, err)), walkPlaybookOut{}, nil } + summary := res.Summary + if outcome == "none" { + // Make the missing proposal impossible to read as success, even + // for a master that ignores the structured ProposalOutcome. + summary = "NO PROPOSAL WAS SUBMITTED — the sub-agent ended without calling a terminal tool (a *_proposal_draft or decline_proposal). Do not report this as a successful proposal.\n\n" + summary + } return nil, walkPlaybookOut{Dispatched: &DispatchedResult{ - Summary: res.Summary, - TimedOut: res.TimedOut, - ExitCode: res.ExitCode, - StderrTail: res.StderrTail, + Summary: summary, + TimedOut: res.TimedOut, + ExitCode: res.ExitCode, + StderrTail: res.StderrTail, + ProposalOutcome: outcome, }}, nil } // cluster_id is required only for investigation-type playbooks (they diff --git a/pkg/mcp/strategies/specs.go b/pkg/mcp/strategies/specs.go index b0ab760c..8edc3192 100644 --- a/pkg/mcp/strategies/specs.go +++ b/pkg/mcp/strategies/specs.go @@ -72,6 +72,12 @@ func ToolSpecs() []toolspec.ToolSpec { Description: "Submit a draft playbook for inline operator review; the launcher renders a diff card with approve/decline.", Inputs: toolspec.FromStruct(proposePlaybookDraftIn{}), }, + { + Server: "triagent-strategies", + Name: "decline_proposal", + Description: "Explicit terminal for a proposal flow that deliberately submits no draft (below the bar). Records a one-line reason; the dispatcher requires a submit OR decline call so it can tell a real decline from an unfinished sub-agent.", + Inputs: toolspec.FromStruct(declineProposalIn{}), + }, { Server: "triagent-strategies", Name: "list_proposals", diff --git a/pkg/mcp/strategies/tools_proposal.go b/pkg/mcp/strategies/tools_proposal.go index 57b51b9f..db3815fd 100644 --- a/pkg/mcp/strategies/tools_proposal.go +++ b/pkg/mcp/strategies/tools_proposal.go @@ -8,11 +8,40 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// Handlers backing the four playbook-proposal tools registered in -// server.go. Kept separate from the existing walker handlers so the -// proposal surface is easy to find and audit; the agent reaches them -// via the playbook_proposal meta-playbook rather than calling them -// directly. +// Handlers backing the playbook-proposal tools registered in server.go. +// Kept separate from the existing walker handlers so the proposal surface +// is easy to find and audit; the agent reaches them via the +// playbook_proposal meta-playbook rather than calling them directly. + +// ── decline_proposal ──────────────────────────────────────────────────────── + +// declineProposalIn is the input for the decline_proposal terminal marker. +type declineProposalIn struct { + Reason string `json:"reason" jsonschema:"One line on why this dispatch is deliberately NOT submitting a proposal (e.g. 'investigation was routine — below the novelty bar'). Required."` +} + +// declineProposalOut acknowledges a deliberate no-proposal terminal. +type declineProposalOut struct { + Acknowledged bool `json:"acknowledged"` + Message string `json:"message"` +} + +// declineProposal is the explicit terminal a proposal-flow sub-agent calls +// when it decides NOT to propose. It exists so the dispatcher can tell a +// deliberate "below the bar" decline from a confabulated submission: exactly +// one of the two terminal tools (propose / decline) must fire, and a prose +// summary is neither. The handler just records the reason; its value is being +// a verifiable tool call. +func (s *Server) declineProposal(_ context.Context, _ *mcp.CallToolRequest, in declineProposalIn) (*mcp.CallToolResult, declineProposalOut, error) { + reason := strings.TrimSpace(in.Reason) + if reason == "" { + return errorResult("reason is required — state in one line why no proposal is being submitted"), declineProposalOut{}, nil + } + return nil, declineProposalOut{ + Acknowledged: true, + Message: "Recorded: no proposal submitted — " + reason, + }, nil +} // ── playbook_schema ───────────────────────────────────────────────────────── diff --git a/pkg/mcp/strategies/tools_proposal_test.go b/pkg/mcp/strategies/tools_proposal_test.go index a1da83e8..7e5efc6c 100644 --- a/pkg/mcp/strategies/tools_proposal_test.go +++ b/pkg/mcp/strategies/tools_proposal_test.go @@ -18,6 +18,27 @@ func newServerWithUserPlaybooksDir(t *testing.T) *Server { return srv } +func TestDeclineProposal_RequiresReason(t *testing.T) { + t.Parallel() + srv := newEmptyServer(t) + res, _, err := srv.declineProposal(context.Background(), nil, declineProposalIn{Reason: " "}) + require.NoError(t, err) + require.NotNil(t, res) + assert.True(t, res.IsError, "an empty reason is an error — the decline must say why") +} + +func TestDeclineProposal_AcknowledgesWithReason(t *testing.T) { + t.Parallel() + srv := newEmptyServer(t) + res, out, err := srv.declineProposal(context.Background(), nil, declineProposalIn{ + Reason: "investigation was routine — below the novelty bar", + }) + require.NoError(t, err) + assert.Nil(t, res, "a valid decline is not an error result") + assert.True(t, out.Acknowledged) + assert.Contains(t, out.Message, "below the novelty bar", "the reason is echoed back so it's auditable") +} + func TestProposePlaybookDraft_ReturnsValidationErrorsInsteadOfErrorResult(t *testing.T) { t.Parallel() srv := newServerWithUserPlaybooksDir(t) diff --git a/pkg/mcp/subagent/subagent.go b/pkg/mcp/subagent/subagent.go index 602d1875..e152e914 100644 --- a/pkg/mcp/subagent/subagent.go +++ b/pkg/mcp/subagent/subagent.go @@ -22,6 +22,7 @@ import ( "os" "os/exec" "path/filepath" + "sort" "strings" "sync" "syscall" @@ -82,6 +83,13 @@ type Options struct { // do their work. Empty (default) keeps the isolation behaviour for // existing callers (git, wiki helper sub-agents, citation runner). MCPConfigPath string + // RequiredTerminalTools is the set of wire tool names that count as a + // valid terminal for this run. When non-empty, Run records which of + // them the sub-agent invoked with a successful result on + // Result.TerminalToolsCalled, so a caller (the strategies dispatch + // path) can tell a sub-agent that actually finished from one that quit + // without reaching a terminal. Empty (default) disables the tracking. + RequiredTerminalTools []string } // Result is the structured outcome of one Run call. @@ -91,6 +99,10 @@ type Result struct { ExitCode int `json:"exit_code,omitempty"` TimedOut bool `json:"timed_out,omitempty"` StderrTail string `json:"stderr_tail,omitempty"` + // TerminalToolsCalled lists the RequiredTerminalTools the sub-agent + // invoked with a non-error result, in sorted order. Empty when none + // fired or when no terminal set was requested. + TerminalToolsCalled []string `json:"terminal_tools_called,omitempty"` // SessionID is the claude conversation id extracted from the first // stream-json system event. Callers thread it back as // Options.ResumeSessionID on subsequent runs to keep the same @@ -162,16 +174,17 @@ func Run(ctx context.Context, opts Options) (Result, error) { stderrCh <- string(b) }() - finalText, sessionID, parseErr := relaySubEvents(stdout, opts.ParentToolID) + relay, parseErr := relaySubEvents(stdout, opts.ParentToolID, terminalSet(opts.RequiredTerminalTools)) waitErr := cmd.Wait() stderrTail := trimTo(<-stderrCh, 800) res := Result{ - Summary: finalText, - PromptSent: opts.Prompt, - StderrTail: stderrTail, - SessionID: sessionID, + Summary: relay.finalText, + PromptSent: opts.Prompt, + StderrTail: stderrTail, + SessionID: relay.sessionID, + TerminalToolsCalled: sortedCalled(relay.terminalsCalled), } if cmd.ProcessState != nil { res.ExitCode = cmd.ProcessState.ExitCode() @@ -188,13 +201,56 @@ func Run(ctx context.Context, opts Options) (Result, error) { return res, nil } +// relayResult is what relaySubEvents returns: the aggregated final text, the +// captured session id, and which of the requested terminal tools the sub-agent +// invoked successfully. +type relayResult struct { + finalText string + sessionID string + terminalsCalled map[string]bool +} + +// terminalSet builds a lookup set from the wire tool names, or nil when none +// are requested. +func terminalSet(tools []string) map[string]struct{} { + if len(tools) == 0 { + return nil + } + out := make(map[string]struct{}, len(tools)) + for _, t := range tools { + out[t] = struct{}{} + } + return out +} + +// sortedCalled flattens the called-terminals set to a sorted slice for a +// stable Result.TerminalToolsCalled. +func sortedCalled(called map[string]bool) []string { + if len(called) == 0 { + return nil + } + out := make([]string, 0, len(called)) + for name, ok := range called { + if ok { + out = append(out, name) + } + } + if len(out) == 0 { + return nil + } + sort.Strings(out) + return out +} + // relaySubEvents reads stream-json lines from claude's stdout and forwards // each event as a nested telemetry pair (start+end) attributed to // parentToolID. Returns the final assistant/result text aggregated across -// `result` and `assistant` events, plus the session_id from the first -// system event (used by callers to thread session resumption across -// retries). -func relaySubEvents(r io.Reader, parentToolID string) (string, string, error) { +// `result` and `assistant` events, the session_id from the first system event +// (used by callers to thread session resumption across retries), and — for any +// tool in `required` — whether the sub-agent invoked it with a non-error +// result (a completed terminal), by correlating tool_use ids with their +// tool_result blocks. +func relaySubEvents(r io.Reader, parentToolID string, required map[string]struct{}) (relayResult, error) { scanner := bufio.NewScanner(r) scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) parser := newStatusMarkerParser(parentToolID, func(parentID, msg string, _ time.Time) { @@ -202,6 +258,11 @@ func relaySubEvents(r io.Reader, parentToolID string) (string, string, error) { }) var finalText strings.Builder var sessionID string + // pendingTerminalIDs maps a required terminal tool's tool_use id to its + // name, set when the tool_use is seen and consumed when its tool_result + // arrives. terminalsCalled records the successful ones. + pendingTerminalIDs := map[string]string{} + terminalsCalled := map[string]bool{} for scanner.Scan() { line := scanner.Bytes() if len(line) == 0 { @@ -239,6 +300,9 @@ func relaySubEvents(r io.Reader, parentToolID string) (string, string, error) { for _, block := range ev.Message.Content { if block.Type == "tool_use" { postNestedToolUse(parentToolID, block) + if _, ok := required[block.Name]; ok && block.ID != "" { + pendingTerminalIDs[block.ID] = block.Name + } } } case "user": @@ -247,6 +311,9 @@ func relaySubEvents(r io.Reader, parentToolID string) (string, string, error) { for _, block := range ev.Message.Content { if block.Type == "tool_result" { postNestedToolResult(parentToolID, block) + if name, ok := pendingTerminalIDs[block.ToolUseID]; ok && !block.IsError { + terminalsCalled[name] = true + } } } case "result": @@ -262,9 +329,9 @@ func relaySubEvents(r io.Reader, parentToolID string) (string, string, error) { } } if err := scanner.Err(); err != nil { - return finalText.String(), sessionID, err + return relayResult{finalText: finalText.String(), sessionID: sessionID, terminalsCalled: terminalsCalled}, err } - return strings.TrimSpace(finalText.String()), sessionID, nil + return relayResult{finalText: strings.TrimSpace(finalText.String()), sessionID: sessionID, terminalsCalled: terminalsCalled}, nil } // streamEvent is the bare-minimum decoder of claude --output-format diff --git a/pkg/mcp/subagent/subagent_test.go b/pkg/mcp/subagent/subagent_test.go index 879dc9ff..09cadec7 100644 --- a/pkg/mcp/subagent/subagent_test.go +++ b/pkg/mcp/subagent/subagent_test.go @@ -338,3 +338,83 @@ func TestPostNestedToolUse_ForwardsBareToolName(t *testing.T) { "ToolName must be the bare MCP tool name; the chat-side ProposalCard match is by exact name") require.Equal(t, map[string]any{"slug": "x"}, ev.Input) } + +// TestRelaySubEvents_TracksRequiredTerminalTools verifies the dispatch +// verification primitive: relaySubEvents reports which required terminal +// tools the sub-agent called with a SUCCESSFUL (non-error) result, by +// correlating tool_use ids with their tool_result blocks. A tool that +// errored doesn't count as a completed terminal. +func TestRelaySubEvents_TracksRequiredTerminalTools(t *testing.T) { + t.Parallel() + const submit = "mcp__triagent-strategies__playbook_proposal_draft" + const decline = "mcp__triagent-strategies__decline_proposal" + required := map[string]struct{}{submit: {}, decline: {}} + + cases := []struct { + name string + stream string + want map[string]bool // tool -> successfully called + }{ + { + name: "successful terminal call counts", + stream: line(map[string]any{"type": "assistant", "message": msg(toolUse(submit, "tu1"))}) + + line(map[string]any{"type": "user", "message": msg(toolResult("tu1", false))}), + want: map[string]bool{submit: true}, + }, + { + name: "errored terminal call does not count", + stream: line(map[string]any{"type": "assistant", "message": msg(toolUse(submit, "tu1"))}) + + line(map[string]any{"type": "user", "message": msg(toolResult("tu1", true))}), + want: map[string]bool{}, + }, + { + name: "decline terminal counts", + stream: line(map[string]any{"type": "assistant", "message": msg(toolUse(decline, "tu9"))}) + + line(map[string]any{"type": "user", "message": msg(toolResult("tu9", false))}), + want: map[string]bool{decline: true}, + }, + { + name: "non-terminal tool is ignored", + stream: line(map[string]any{"type": "assistant", "message": msg(toolUse("Bash", "tu2"))}) + line(map[string]any{"type": "user", "message": msg(toolResult("tu2", false))}), + want: map[string]bool{}, + }, + { + name: "no terminal call leaves the set empty", + stream: line(map[string]any{"type": "assistant", "message": msg(textBlock("just talking"))}), + want: map[string]bool{}, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + res, err := relaySubEvents(strings.NewReader(tc.stream), "", required) + require.NoError(t, err) + for tool, wantCalled := range tc.want { + require.Equal(t, wantCalled, res.terminalsCalled[tool], "terminal %q", tool) + } + // Nothing outside the wanted set is marked called. + for tool, got := range res.terminalsCalled { + if got && !tc.want[tool] { + t.Errorf("unexpected terminal marked called: %q", tool) + } + } + }) + } +} + +func line(v map[string]any) string { + b, _ := json.Marshal(v) + return string(b) + "\n" +} +func msg(blocks ...map[string]any) map[string]any { + return map[string]any{"content": blocks} +} +func toolUse(name, id string) map[string]any { + return map[string]any{"type": "tool_use", "name": name, "id": id} +} +func toolResult(toolUseID string, isError bool) map[string]any { + return map[string]any{"type": "tool_result", "tool_use_id": toolUseID, "is_error": isError} +} +func textBlock(text string) map[string]any { + return map[string]any{"type": "text", "text": text} +} diff --git a/pkg/mcp/teleport/server.go b/pkg/mcp/teleport/server.go index 5095f85d..26f5b94d 100644 --- a/pkg/mcp/teleport/server.go +++ b/pkg/mcp/teleport/server.go @@ -20,7 +20,14 @@ type Options struct { // already have a context provisioned; login writes new contexts into // it (via the SDK). KubeconfigPath string - // Provider is injectable for tests. nil means use authteleport.NewProvider(). + // Proxy and AuthConnector are the deployment's Teleport connection + // parameters, sourced from the profile's auth.teleport block. They + // drive both the `tsh login` the SDK runs and the re-auth advice the + // tools surface; left empty the SDK falls back to its compiled defaults. + Proxy string + AuthConnector string + // Provider is injectable for tests. nil means build one from Proxy / + // AuthConnector via authteleport.NewProvider(). Provider TeleportProvider } @@ -30,6 +37,10 @@ type TeleportProvider interface { IsAuthenticated() bool ListClusters(ctx context.Context) ([]auth.Cluster, error) Login(ctx context.Context, cluster string) (*auth.LoginResult, error) + // ReauthAdvice renders the user-facing `tsh login` instruction with the + // deployment's configured proxy/connector. Sourcing the auth-required + // message from here keeps it in sync with the actual login command. + ReauthAdvice() string } // Server holds the configured MCP server. @@ -46,7 +57,17 @@ func New(opts Options) (*Server, error) { } provider := opts.Provider if provider == nil { - provider = authteleport.NewProvider(authteleport.Config{}) + // NewProvider returns auth.Provider; ReauthAdvice is an optional + // interface the Teleport adapter always satisfies. Assert it so the + // auth-required message can render the configured proxy/connector. + built, ok := authteleport.NewProvider(authteleport.Config{ + Proxy: opts.Proxy, + AuthConnector: opts.AuthConnector, + }).(TeleportProvider) + if !ok { + return nil, fmt.Errorf("teleport auth provider does not implement ReauthAdvice") + } + provider = built } impl := mcp.NewServer(&mcp.Implementation{ Name: "triagent-mcp-teleport", diff --git a/pkg/mcp/teleport/tools_clusters.go b/pkg/mcp/teleport/tools_clusters.go index 46a891fd..7887f189 100644 --- a/pkg/mcp/teleport/tools_clusters.go +++ b/pkg/mcp/teleport/tools_clusters.go @@ -6,7 +6,6 @@ import ( "sort" "strings" - authteleport "github.com/sourcehawk/triagent/pkg/auth/teleport" "github.com/modelcontextprotocol/go-sdk/mcp" "k8s.io/client-go/tools/clientcmd" ) @@ -33,19 +32,11 @@ type ListClustersOutput struct { Items []ClusterInfo `json:"items"` } -// authRequiredMessage is the error returned when the operator's Teleport -// session is missing or expired. The proxy + connector come from the auth/teleport -// package so this message stays in sync with the actual `tsh login` invocation. -var authRequiredMessage = fmt.Sprintf( - "Teleport session expired or missing — run `tsh login --proxy=%s --auth=%s` in your terminal, then retry.", - authteleport.DefaultProxyAddr, authteleport.DefaultAuthConnector, -) - // listClusters enumerates Teleport-reachable Kubernetes clusters. Auth failures // surface a clear message telling the operator which `tsh login` to run. func (s *Server) listClusters(ctx context.Context, _ *mcp.CallToolRequest, in ListClustersInput) (*mcp.CallToolResult, ListClustersOutput, error) { if !s.provider.IsAuthenticated() { - return errorResult(authRequiredMessage), ListClustersOutput{}, nil + return errorResult(s.provider.ReauthAdvice()), ListClustersOutput{}, nil } clusters, err := s.provider.ListClusters(ctx) if err != nil { diff --git a/pkg/mcp/teleport/tools_clusters_test.go b/pkg/mcp/teleport/tools_clusters_test.go index 13145d70..ddacb88e 100644 --- a/pkg/mcp/teleport/tools_clusters_test.go +++ b/pkg/mcp/teleport/tools_clusters_test.go @@ -22,6 +22,7 @@ type fakeProvider struct { loginResult *auth.LoginResult loginErr error loginCalls []string + reauthAdvice string } func (f *fakeProvider) IsAuthenticated() bool { return f.authenticated } @@ -32,6 +33,12 @@ func (f *fakeProvider) Login(_ context.Context, c string) (*auth.LoginResult, er f.loginCalls = append(f.loginCalls, c) return f.loginResult, f.loginErr } +func (f *fakeProvider) ReauthAdvice() string { + if f.reauthAdvice != "" { + return f.reauthAdvice + } + return "Teleport session expired or missing — run `tsh login` in your terminal, then retry" +} // kubeconfigWithContexts writes a minimal kubeconfig with the named contexts. func kubeconfigWithContexts(t *testing.T, names ...string) string { @@ -182,3 +189,45 @@ func TestListClusters_AuthRequired(t *testing.T) { require.True(t, ok) assert.Contains(t, tc.Text, "tsh login", "auth-failure message must name the command to run") } + +// The auth-required message must come from the provider so it reflects the +// deployment's configured proxy/connector — not a static string baked against +// the empty package defaults (which produced `tsh login --proxy= --auth=okta`). +func TestAuthRequiredMessage_ComesFromProvider(t *testing.T) { + t.Parallel() + provider := &fakeProvider{authenticated: false, reauthAdvice: "SENTINEL-REAUTH-ADVICE"} + srv := newTestServer(t, kubeconfigWithContexts(t), provider) + + clusters, _, err := srv.listClusters(context.Background(), nil, ListClustersInput{}) + require.NoError(t, err) + require.True(t, clusters.IsError) + assert.Contains(t, clusters.Content[0].(*mcpTextContent).Text, "SENTINEL-REAUTH-ADVICE") + + login, _, err := srv.login(context.Background(), nil, LoginInput{Cluster: "prod-eu-1"}) + require.NoError(t, err) + require.True(t, login.IsError) + assert.Contains(t, login.Content[0].(*mcpTextContent).Text, "SENTINEL-REAUTH-ADVICE") +} + +// Regression for the empty-proxy bug: a teleport deployment with a configured +// proxy must see that proxy in the re-auth advice. Constructs the real +// provider (no stub) with an empty TELEPORT_HOME so it reads as unauthenticated +// deterministically, then asserts the proxy threaded through New → provider → +// message. +func TestNew_ThreadsConfiguredProxyIntoAuthMessage(t *testing.T) { + t.Setenv("TELEPORT_HOME", t.TempDir()) + t.Setenv("HOME", t.TempDir()) + srv, err := New(Options{ + KubeconfigPath: kubeconfigWithContexts(t), + Proxy: "proxy.example.com", + AuthConnector: "okta", + }) + require.NoError(t, err) + + res, _, err := srv.listClusters(context.Background(), nil, ListClustersInput{}) + require.NoError(t, err) + require.True(t, res.IsError, "no Teleport session in test env, so auth must be required") + text := res.Content[0].(*mcpTextContent).Text + assert.Contains(t, text, "--proxy=proxy.example.com", "configured proxy must reach the message") + assert.Contains(t, text, "--auth=okta") +} diff --git a/pkg/mcp/teleport/tools_login.go b/pkg/mcp/teleport/tools_login.go index bef8c86f..e7267aef 100644 --- a/pkg/mcp/teleport/tools_login.go +++ b/pkg/mcp/teleport/tools_login.go @@ -23,7 +23,7 @@ type LoginOutput struct { // triagent-k8s.switch_context to start querying that cluster. func (s *Server) login(ctx context.Context, _ *mcp.CallToolRequest, in LoginInput) (*mcp.CallToolResult, LoginOutput, error) { if !s.provider.IsAuthenticated() { - return errorResult(authRequiredMessage), LoginOutput{}, nil + return errorResult(s.provider.ReauthAdvice()), LoginOutput{}, nil } if in.Cluster == "" { return errorResult("cluster is required"), LoginOutput{}, nil diff --git a/system/capture_offer.yaml b/system/capture_offer.yaml index afb6f324..be1b5c0c 100644 --- a/system/capture_offer.yaml +++ b/system/capture_offer.yaml @@ -132,6 +132,13 @@ nodes: the master investigation's session id (NOT this `capture_offer` session) so the proposal lives as a sibling of the original investigation in the activity panel. + The dispatched sub-agent has NO access to this investigation — + your `notes` are its only context. Write a complete, self- + contained brief: the symptom, the key findings and evidence, the + root cause and resolution, and the actual prose and sections that + should make up the wiki entry. If a detail belongs in the entry, + write it out in full here — the sub-agent cannot see anything you + have seen. handoff: - wiki_proposal @@ -141,6 +148,13 @@ nodes: Hand off to `playbook_proposal`. Pass `parent_session_id` set to the master investigation's session id (NOT this `capture_offer` session). + The dispatched sub-agent has NO access to this investigation — + your `notes` are its only context. Write a complete, self- + contained brief: the symptom, the key findings and evidence, the + root cause and resolution, and the playbook's actual shape — the + node descriptions, the suggested_calls each step should run, and + the terminals. If a detail belongs in the playbook, write it out + in full here — the sub-agent cannot see anything you have seen. handoff: - playbook_proposal @@ -169,7 +183,10 @@ nodes: Run wiki first, then playbook, then codefix. The codefix issue body can cite the freshly-promoted wiki entry. For each, hand off via `walk_playbook` and pass `parent_session_id` - set to the master investigation's session id. + set to the master investigation's session id. Each dispatched + sub-agent has NO access to this investigation — its `notes` are + its only context, so give each a complete, self-contained brief + with every detail that belongs in that artifact. handoff: - wiki_proposal - playbook_proposal