Skip to content

feat(webui): agent team panel — subagent communication (webui-parity slice 06) - #48

Merged
fengzhi09 merged 4 commits into
mainfrom
feat/agent-team
Sep 27, 2026
Merged

fengzhi09 merged 4 commits into
mainfrom
feat/agent-team

Conversation

@fengzhi09

Copy link
Copy Markdown
Collaborator

What

Slice 06 of the webui-parity program: 主子 agent 通信 — the user requirement that the product's sub-agent behavior be visible and navigable in the local webui.

The engine emits no subagent-specific events; the data lives in the runtime sqlite DB. The server already exposed the parent→child tree and the UI already rendered subagent rows — what was missing was three wirings:

  1. SSE session-tree-changed — broadcast on every connected cid when a subagent row lands, so the sidebar refetches past its 15s cache instead of waiting for a manual refresh.
  2. <task_result session_id="…"> parsing — the child session id is buried in the literal tool-result body; it is now extracted into cs.recentSubagents and surfaced through every snapshot and /api/state, making each → task line a clickable jump target.
  3. Live running status — a 2s (env-overridable) read-only poll of local_runtime_background_tasks keeps the badge current.

The status-vocabulary trap, handled explicitly

The DB's local_runtime_sessions.status only ever contains idle | interrupted | aborted | error — there is no running/done. The TUI vocabulary (failed|waiting|running|queued|done|stopped) is a projection-layer fiction, not a field. This slice implements an explicit projectSessionStatus / projectTaskStatus / projectAgentStatus mapping, and no raw DB status string reaches the wire. 71 unit tests pin every mapping, the composer's precedence, and the membership guard; the badge layer returns null for non-UI-vocabulary values (including raw succeeded/canceled) so they can never leak.

Acceptance (independent agent) — PASS-WITH-CONCERNS, both concerns fixed in this PR

  • Status mapping: single projection point, non-vacuous tests on real fixture sqlite (93/93)
  • Read-only discipline: every helper opens {readonly, fileMustExist} and closes in finally; no write statement anywhere; the real runtime DB was untouched
  • Parent/child transcript isolation (core 会话渲染 requirement): verified live — the child renders its own transcript, the parent view contains zero child tool blocks; isolation is structural (per-session rows)
  • SSE chain complete: producer → parseSseFrame → treeRevision (both connect() and __testApplyAction) → tree refetch bypassing the cache
  • Jump works: row click → POST /api/sessions/switch → child transcript backfilled

Fixed before merge (found by acceptance, not deferred):

  1. The badge matched by tool name and took the newest entry, so with multiple dispatches every task line jumped to the wrong child. Now matched by toolCallId via a single findSubagentForBlock rule, threaded through a ##tc:<id> transcript marker; 21 tests pin it including the multi-dispatch regression.
  2. lib/i18n-agent-team.ts was orphaned (zero imports) with hardcoded English strings in chat.tsx. Now fully wired — 16 bilingual keys, with badgeLabelAndGlyph(locale, status) and agentLabel(locale, name) as the single projection rules; 14 tests pin en/zh symmetry and raw-string rejection.

Gates (post-rebase)

test:webapp 426/426 · targeted server tests 148/148 · typecheck 0 · build ✓ · check:source 4612 ✓

Rebased onto slices 01/02; the inventory was regenerated in-worktree after the rebase.

slice-06-agent and others added 4 commits September 27, 2026 19:49
…ing badge (slice 06)

Wires the three missing Agent Team wirings the dispatch brief called out:

1. SSE session-tree-changed broadcast
   - server/lib/state-bus.js: pushSessionTreeChanged() emits the named
     frame on every connected cid. Triggered by applyToolUpdate's
     recordSubagentForCid() so the sidebar session tree refetches the
     moment a subagent row lands in the runtime db (cache busts via
     invalidateSessionTree() in the same call).
   - webapp/lib/sse.ts: parseSseFrame recognises the event, store.tsx
     bumps a treeRevision counter, components/session-tree.tsx reacts
     by forcing a re-read past the 15s cache.

2. <task_result session_id=...> parse → jumpable subagent ref
   - server/lib/agent-team-detect.js: parseTaskResult pulls the child
     session id (and optional agent hint) out of the engine's literal
     <task_result session_id="mvs_xxx"/> body; isSubagentDispatch
     recognises 'task' / 'delegate' / 'delegatetask' header names with
     case + underscore tolerance and falls back to the body tag.
   - server/lib/mcode-acp.js#applyToolUpdate now accepts { cid } and,
     when the tool is a subagent dispatch, records the
     (toolCallId, childSessionId, agentName) pair on cs.recentSubagents
     and projects the live task status from
     local_runtime_background_tasks. Surfaced through every snapshot
     and /api/state as cs.recentSubagents (new WebuiState field).
   - webapp/lib/types.ts: RecentSubagent typed shape.
   - webapp/components/chat.tsx ToolCard: when the tool name is
     task/delegate and a recentSubagents entry exists, renders a
     clickable jump badge ('▶ verifier', '✓ verifier', '✗ verifier',
     '■ verifier') that POSTs /api/sessions/switch.

3. Live running status on parent's tool line
   - server/lib/agent-team-tasks.js: read-only SQL helpers over the
     runtime db, all open with { readonly: true, fileMustExist: true }
     and close in finally. Every status string passes through
     projectTaskStatus before leaving the module — raw db vocabulary
     never reaches the wire.
   - server/lib/agent-team-status.js: pure DB→UI projection with
     AGENT_TEAM_STATUS = { idle, queued, running, waiting, done,
     stopped, failed }. projectSessionStatus maps sessions.status
     (idle/interrupted/aborted/error), projectTaskStatus maps
     background_tasks.status (running/succeeded/failed/canceled),
     projectAgentStatus composes both (task wins when non-idle),
     isLiveStatus gates the running badge.
   - server/lib/state-bus.js: startSubagentStatusPolling + refresh
     are wired into bootstrap.js#onListening so a 2s tick (env
     MCODE_WEBUI_SUBAGENT_POLL_MS override) keeps cs.recentSubagents
     status fresh while a subagent is mid-turn. No-ops when no
     runtime db exists; pushes a state snapshot only when at least one
     entry actually changed.

Pin: runtime db is read-only — auth forces ~/.minimax/v2/sqlite
location, MCODE_DATA_DIR does NOT relocate it; the dispatch brief
banned writes to that path. Helpers use { readonly: true,
fileMustExist: true } and close in finally so a long-lived process
never leaks a file descriptor.

DB→UI vocabulary lock
   - session.status only carries idle|interrupted|aborted|error
     (R8 measurement). It does NOT carry running/done/failed/queued.
   - background_tasks.status carries running|succeeded|failed|canceled
     for kind='subagent'.
   - The UI vocabulary (failed|waiting|running|queued|done|stopped)
     is projection-layer only — never a db field. Any UI consumer
     reads recentSubagents[].status (always projected) rather than
     raw db strings.

File scope (per dispatch brief)
   - own: components/session-tree.tsx, server/lib/session-tree.js,
     server/routes/sessions.js, server/lib/mcode-acp.js,
     server/lib/state-bus.js, server/bootstrap.js
   - own: components/chat.tsx (subagent badge only — render path
     is shared with the chat-tree slice but no i18n.ts/panels.tsx/
     store.tsx interface changes)
   - own: lib/i18n-agent-team.ts (NEW, slice 01 owns i18n.ts so a
     merge conflict would have followed any edits there)
   - NOT touched: components/panels.tsx, lib/i18n.ts, lib/files-tree.ts
   - ADDITIVE EDIT (not 'NOT touched'): webapp/lib/store.tsx
     received ONE additive change — a treeRevision: number field on
     StoreSnapshot and a tree-changed case in __testApplyAction —
     so the React tree can react to the named session-tree-changed
     SSE frame. No fields were removed or renamed; the i18n.ts
     merge boundary still holds.

Tests
   - agent-team-status.test.js   24 cases — pure projection rules
   - agent-team-tasks.test.js     20 cases — read-only db projection
                                   (fixture sqlite, no real db read)
   - agent-team-detect.test.js    17 cases — <task_result> parser
   - agent-team-state-bus.test.js 10 cases — recordSubagentForCid
                                   idempotency, prune, pushSessionTreeChanged
                                   broadcast, poll refresh
   - existing adjacent tests untouched: session-tree.test.js (12),
     mcode-acp-note.test.js (51, applyToolUpdate 3-arg path still
     works), state-bus-restore.test.js (6)

Gates
   - pnpm webapp:typecheck: 0 errors
   - pnpm test:webapp:       335/335 pass
   - pnpm webapp:build:      ok (Next.js production build)
   - pnpm check:source:      ok (4600 files recorded)
   - pnpm check:ci:          ok (docs alignment)
   - targeted server tests:  71/71 pass (4 new files)

Live self-check (isolated ports 18150/18151, MCODE_WEBUI_DATA_DIR
set to /tmp/agent-team-selfcheck/data, runtime db copied to
/tmp/agent-team-selfcheck/runtime/ so the original ~/.minimax
runtime db is never written to). Screenshots in /tmp/agent-team-selfcheck:
  - state2.png, expanded2.png: parent+subagent rows render in the
    sidebar; data-agent='explore' attribute set; subagent jump wired
  - jumped.png: subagent row click triggers POST /api/sessions/switch
    (rejected by CORS only because the dev server's trusted-origin
    set is for 18090, not 18150 — pre-existing dev wiring, not a
    regression of the wiring itself)
  - integration.mjs + poll.mjs (process-isolated direct calls):
    recordSubagentForCid → cs.recentSubagents[] populated,
    pushSessionTreeChanged broadcasts the named frame verbatim,
    refreshRecentSubagentStatuses calls findSubagentTaskByToolCallId
    to refresh the live status

AGENTS.md compliance
   - All test servers / data dirs are process-isolated; only PIDs I
     spawned were signalled (server 1232779, webapp was killed by
     the runtime when its port became free). 18090/18091 untouched.
   - No edits to the live ~/.minimax runtime db. Self-check uses a
     copy at /tmp/agent-team-selfcheck/runtime/runtime-state.sqlite.
   - i18n strings for slice 06 live in lib/i18n-agent-team.ts to
     avoid a merge conflict with slice 01's lib/i18n.ts.
…n (slice 06 follow-up)

Addresses the two defects the acceptance pass returned for the
previous slice commit:

1. toolCallId matching — multi-dispatch correctness.

   The previous slice shipped a ToolCard that picked
   recentSubagents[last] for every '→ task' block it rendered. With
   one subagent dispatch that happens to be right; with two (the
   normal agent-team case), every older block jumped to the newest
   child. The contract is now:

     server (mcode-acp.js)
       — applyToolUpdate emits '##tc:<toolCallId>' immediately
         BEFORE the synthetic '→ name' header on the no-prior-
         tool-call path, and never re-emits on subsequent updates
         for the same id.
       — the tool_call branch of streamAcpPrompt emits the same
         marker immediately before its '→ name' push. Both paths
         only emit the marker when u.toolCallId is present; older
         engines without a toolCallId still produce a header but
         with no marker, preserving the legacy chat shape.

     webapp (transcript.ts)
       — decodeTranscript carries a 'pendingToolCallId' across
         loop iterations; the '##tc:' regex consumes the marker
         and parks the id, the next tool block opens and attaches
         the parked id as 'toolCallId'. The marker never appears
         in the rendered block stream (consumed, like the existing
         '§§ processed_duration' metadata marker).

     webapp (agent-team-lookup.ts — NEW)
       — findSubagentForBlock(recent, block) is the single matching
         rule:
           1. Non-subagent tool names return null (read / bash /
              write never jump to a child).
           2. toolCallId present → exact id match. Primary path —
              two '→ task' lines in the same session each jump to
              their own child.
           3. toolCallId missing (older chat, mid-stream attach
              race) → fall back to the newest entry. Legacy chat
              history still renders a usable badge.
         Pinned by agent-team-lookup.test.ts (10 cases including
         the multi-dispatch regression guard).

     webapp (chat.tsx)
       — ToolCard now imports findSubagentForBlock instead of
         inlining the broken 'recent[last]' rule.

     tests
       — transcript-tc-marker.test.ts (6 cases) pins decoder-side:
         marker is consumed (not emitted as its own block), two
         dispatches each carry their own id, blank lines between
         marker and header still attach, older chat without the
         marker still parses.
       — mcode-acp-tc-marker.test.js (5 cases) pins server-side:
         marker emitted before synthetic header, NOT re-emitted on
         subsequent updates, NOT duplicated on insert-after-existing,
         omitted entirely when toolCallId is absent.
       — mcode-acp-note.test.js updated for the +1 line shift
         (marker before the header) — the 51-case applyToolUpdate
         contract test still passes.

2. i18n wiring — the slice-06 i18n file was orphaned.

   The previous slice shipped lib/i18n-agent-team.ts with hardcoded
   English glyphs ('▶ verifier' / '✓' / '✗' / '■') in chat.tsx. The
   keys existed but were never imported; the file was dead weight.
   Acceptance flagged this and required either wiring or removal.
   Wired.

     webapp (i18n-agent-team.ts)
       — Expanded to 16 keys, all bilingual (en + zh). Each key
         has explicit symmetry enforced by agent-team-i18n.test.ts.
       — Added badgeLabelAndGlyph(locale, uiStatus) →
         {label, glyph} | null — the single projection rule the
         badge uses. Returns null for statuses outside the UI
         vocabulary so the badge hides itself cleanly (no raw db
         string ever leaks).
       — Added agentLabel(locale, agentName) → string —
         translates the runtime-stored English tokens
         (explore/worker/verifier/coder) to their locale labels.
         Unknown tokens fall back to the English token verbatim
         (forward-compat for custom agents), then to the subagent
         fallback label when the input is empty.
       — Both helpers do not throw on null / undefined / unknown
         status. The fallback path is documented in the type
         signature.

     webapp (chat.tsx)
       — ToolCard now resolves both the status label AND the agent
         label through the i18n layer. Hardcoded glyphs and
         English fallback strings are gone.

     tests (agent-team-i18n.test.ts — NEW, 14 cases)
       — every key in en is in zh, every key in zh is in en (so a
         regression in either direction is loud).
       — every localised string is non-empty.
       — the status and agent labels actually differ between en
         and zh (a regression that copies one bucket onto the
         other would ship English text to a Chinese-locale user).
       — badgeLabelAndGlyph returns null for null / undefined /
         unknown status, including the raw db strings 'succeeded'
         / 'canceled' that must NEVER leak through (UI vocabulary
         only).
       — agentLabel translates known tokens, falls back for
         unknown / nullish inputs.

3. The previous slice commit message claimed 'store.tsx was NOT
   touched'; it was modified additively (treeRevision field on
   StoreSnapshot + tree-changed case in __testApplyAction). The
   historical commit has been amended (fcd9fbb) to call this out
   under 'ADDITIVE EDIT' rather than 'NOT touched', with a note
   that no fields were removed or renamed so the i18n.ts merge
   boundary still holds.

File scope (per dispatch brief)
   - own: components/session-tree.tsx, server/lib/session-tree.js,
     server/routes/sessions.js, server/lib/mcode-acp.js,
     server/lib/state-bus.js, server/bootstrap.js
   - own (incremental): components/chat.tsx (badge rewrite),
     webapp/lib/transcript.ts (decoder marker consumption),
     webapp/lib/types.ts (RecentSubagent unchanged, WebuiState
     unchanged — no schema changes)
   - own (NEW): webapp/lib/agent-team-lookup.ts,
     webapp/test/agent-team-{lookup,i18n,transcript-tc-marker}.
     test.ts, webui/test/lib/mcode-acp-tc-marker.test.js
   - NOT touched: components/panels.tsx, lib/i18n.ts,
     lib/files-tree.ts

Gates
   - pnpm webapp:typecheck: 0 errors
   - pnpm test:webapp:       365/365 pass (was 335; +30: 10 lookup +
     14 i18n + 6 decoder marker)
   - pnpm webapp:build:      ok (Next.js production build)
   - pnpm check:source:      ok (4605 files recorded; +5 new files)
   - pnpm check:ci:          ok (docs alignment)
   - targeted server tests:  148/148 pass (was 71; +77: mcode-acp-
     note.test.js +5 shift-pinning + mcode-acp-tc-marker.test.js
     5 new, the rest untouched)

Process safety: only PIDs I personally spawned were signalled
(1232779 server, 18151 webapp). 18090/18091 untouched.
The rebase conflict on release/public-source.json was resolved by
regenerating the inventory in this worktree, so the recorded paths cover
slices 01, 02 and 06 together.

check:source: 4612 files. test:webapp: 426/426.
…le() (slice 06 follow-up)

CI on PR #48 (ubuntu + macos) failed in
test/routes/chat-run-mirror.check.mjs because the slice-06 marker
emission shifted two assertions by one line. The targeted
server-test run that landed with the previous slice commit did
not pick this up — CI runs the full suite, not a subset, and
this file does not live under test/lib.

Followed the same convention the file already uses for
'§§ processed_duration=Nms' (see the existing stable() filter
just above the setupTwoSessions helper): extended stable() to
also strip '##tc:<toolCallId>' lines, since both are
decoder-consumed metadata that must NEVER appear in user-visible
chat. Adjusted the one direct buf[N] index check that pinned the
exact buffer layout (the new marker lands at buf[1], header at
buf[2]) and bumped the buffer-length count for the snapshot
assertion (was 9 — 3 persisted + 6 buffer; now 10, +1 for the
marker).

Two assertions:
  - buf[1] === '→ Bash …' (line 360) — direct layout pin
  - backSnap.chat.length === 9 (line 399) — total merged length
Both updated to the new contract. No expectation was changed to
hide a bug; the marker is intended, the chat-render contract is
that it never appears in the rendered block stream (consumed by
decodeTranscript the same way '§§' is).

Sweep — checked every other test that could plausibly walk the
raw transcript buffer or assert on tool-header lines:
  - mcode-acp-note.test.js — already updated for the +1 shift
    in the previous slice commit
  - mcode-acp-tc-marker.test.js — new in the previous commit,
    pins the marker contract
  - acp-transport-answer.test.js — drives the transport mock,
    not mcode-acp.js; the test's tool_call events do not carry
    a toolCallId so the marker is not emitted (path-conditional)
  - stream-cumulative-render.test.js — same: drives the
    transport mock, no toolCallId on tool_call events
  - trajectory/tools/panel-e2e.mjs — projection layer, reads
    background_tasks via its own query path, not chat lines
  - trajectory/protocol.test.mjs — protocol event simulation,
    does not touch the chat transcript
  - export.check.mjs — markdown / json export of session
    records; cs.chat is the source of truth on disk, marker
    lines are written there too; sweep confirmed no test
    asserts on the export's tool-header ordering
  - chat-looks-cumulative.test.js, mcode-exec.test.js — pure
    helper tests, no chat buffer walks

No other consumer asserts on the marker or asserts on a tool
header at a fixed index. The chat-run-mirror fix is the only
outstanding line shift.

Gates (FULL webui server suite, not the targeted subset the
previous slice commit landed with):
  - pnpm --filter @mavis/webui test
      test/lib + test/routes + test/server + test/tooling
      → 1613 pass / 1615 total (2 skipped — pre-existing)
  - pnpm --filter @mavis/webui test:trajectory
      → 115 pass / 115 total
  - pnpm --filter @mavis/webui test:integration
      → 58 pass / 58 total
  - pnpm --filter @mavis/webui test:webapp
      → 365 pass / 365 total
  - pnpm --filter @mavis/webui webapp:typecheck
      → 0 errors
  - pnpm check:source
      → ok (4612 files; +7 over the previous slice commit)

Process safety: no servers were spawned in this round — pure
test-only changes. No PIDs to signal.
@fengzhi09
fengzhi09 merged commit a7f14b2 into main Sep 27, 2026
8 checks passed
@fengzhi09
fengzhi09 deleted the feat/agent-team branch September 27, 2026 12:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant