Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a631f18
update images
sourcehawk Jun 3, 2026
a3929bc
fix(teleport): gate teleport MCP on auth.kind and thread proxy/connec…
sourcehawk Jun 3, 2026
ea5571f
fix(stream): register dropped SSE kinds so live updates aren't lost
sourcehawk Jun 3, 2026
f81472d
fix(wiki): surface sub-agent-drafted proposals via card hoist + globa…
sourcehawk Jun 3, 2026
448b8a6
fix(repos): refresh all repo lists live on add/remove via a shared event
sourcehawk Jun 3, 2026
e55ef9f
feat(strategies): harden proposal dispatch with an explicit terminal …
sourcehawk Jun 3, 2026
2b661b1
test(e2e): verify nested sub-agent proposals surface (backend)
sourcehawk Jun 3, 2026
2236055
test(e2e): verify nested wiki + playbook proposal cards render (browser)
sourcehawk Jun 3, 2026
e01adfe
docs(spec): design for verifying proposal-dispatch terminals
sourcehawk Jun 3, 2026
b3badb4
feat(strategies): verify proposal-dispatch terminals with resume-force
sourcehawk Jun 3, 2026
f798791
fix(sessions): don't render a proposal card for a validation-failure …
sourcehawk Jun 3, 2026
4230d5e
feat(strategies): teach the proposal dispatch how to finish
sourcehawk Jun 3, 2026
8681234
feat(strategies): tell the master the proposal sub-agent is context-b…
sourcehawk Jun 3, 2026
b5e5a33
fix(playbooks): open a brand-new playbook proposal from the sidebar
sourcehawk Jun 3, 2026
ecdcd99
fix(sidebar): don't nest interactive controls inside the investigatio…
sourcehawk Jun 3, 2026
da6c124
test(watches): flush WatchForm's mount fetch inside act
sourcehawk Jun 3, 2026
ccb8940
docs: remove the proposal-dispatch verification design doc
sourcehawk Jun 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 23 additions & 2 deletions cmd/triagent-mcp/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -69,6 +72,10 @@ type serveFlags struct {
crdsFile string
crossplaneGroups string

// teleport flags
teleportProxy string
teleportAuthConnector string

// strategies flags
sessionDir string
userPlaybooksDir string
Expand Down Expand Up @@ -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]")
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand Down
15 changes: 15 additions & 0 deletions cmd/triagent-mcp/serve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package main
import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

Expand All @@ -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)
}
Binary file modified docs/images/playbook-catalog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/playbook-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/repositories.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/session-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/watches-screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-catalog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/wiki-editor.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
45 changes: 45 additions & 0 deletions e2e/browser/specs/nested-proposals.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
30 changes: 30 additions & 0 deletions e2e/browser/specs/playbook-proposal-view.spec.ts
Original file line number Diff line number Diff line change
@@ -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=<id>&proposal=<pid>&tab=proposal; getPlaybook(<id>) 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");
});
});
38 changes: 38 additions & 0 deletions e2e/browser/specs/wiki-proposal-view.spec.ts
Original file line number Diff line number Diff line change
@@ -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=<slug>&proposal=<pid>&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 });
});
});
42 changes: 24 additions & 18 deletions e2e/cmd/claude-stub/proposal.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
54 changes: 54 additions & 0 deletions e2e/cmd/claude-stub/proposal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion e2e/cmd/claude-stub/script.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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."
18 changes: 18 additions & 0 deletions e2e/fixtures/stub-scripts/nested-proposals/main.jsonl
Original file line number Diff line number Diff line change
@@ -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}
Loading
Loading