From 0ff7fd710fc29899f88280aa1145ee297704ce30 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 01:48:39 +0800 Subject: [PATCH 1/7] fix(kap-server): carry the live subagent roster in the session snapshot --- .changeset/fix-kap-server-snapshot-roster.md | 5 + packages/kap-server/src/routes/snapshot.ts | 1 + .../src/services/snapshot/snapshotReader.ts | 1 + .../ws/v1/sessionEventBroadcaster.ts | 16 ++- .../transport/ws/v1/subagentRosterTracker.ts | 122 +++++++++++++++++ .../test/sessionEventBroadcaster.test.ts | 43 ++++++ packages/kap-server/test/snapshot.test.ts | 24 ++++ .../test/snapshotReader.unit.test.ts | 2 + .../test/subagentRosterTracker.test.ts | 128 ++++++++++++++++++ 9 files changed, 339 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-kap-server-snapshot-roster.md create mode 100644 packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts create mode 100644 packages/kap-server/test/subagentRosterTracker.test.ts diff --git a/.changeset/fix-kap-server-snapshot-roster.md b/.changeset/fix-kap-server-snapshot-roster.md new file mode 100644 index 0000000000..63036c6dd2 --- /dev/null +++ b/.changeset/fix-kap-server-snapshot-roster.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Restore the AgentSwarm member list after a page refresh on the v2 backend. diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index 8bf8c4fc53..8871d58c37 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -201,6 +201,7 @@ async function readViaLegacyAssembly( session, messages: { items, has_more: hasMore }, in_flight_turn: inFlightTurn, + subagents: snapState.subagents, pending_approvals: pendingApprovals, pending_questions: pendingQuestions, }; diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index ac94287687..9a85e20483 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -148,6 +148,7 @@ export class SnapshotReader implements ISnapshotReader { session, messages: { items, has_more: hasMore }, in_flight_turn: inFlightTurn, + subagents: snapState.subagents, pending_approvals: approvals, pending_questions: questions, }; diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 0092860ee7..14e44cdfb5 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -60,6 +60,7 @@ import type { SessionCursor, SessionMetaUpdatedEvent, SessionStatus, + SnapshotSubagent, } from '@moonshot-ai/protocol'; import { isVolatileEventType } from '@moonshot-ai/protocol'; @@ -67,6 +68,7 @@ import { toWireApproval } from '../../../routes/approvals'; import { toWireQuestion } from '../../../routes/questions'; import { readLegacyStatus, toLegacyPhase } from '../../../services/legacyStatus/legacyStatus'; import { InFlightTurnTracker } from './inFlightTurnTracker'; +import { SubagentRosterTracker } from './subagentRosterTracker'; import { type EventEnvelope, type JournalLogger, @@ -88,6 +90,7 @@ export interface SessionSnapshotState { seq: number; epoch: string; inFlightTurn: InFlightTurn | null; + subagents: SnapshotSubagent[]; } /** A connection (or test double) that receives sequenced envelopes. */ @@ -107,6 +110,7 @@ interface SessionState { readonly sessionId: string; readonly journal: SessionEventJournal; readonly tracker: InFlightTurnTracker; + readonly roster: SubagentRosterTracker; readonly activity?: ISessionActivity; /** Last status emitted (initialized from live session activity). */ lastStatus?: SessionStatus; @@ -233,14 +237,15 @@ export class SessionEventBroadcaster { if (state === undefined) { const cold = await this.readColdWatermark(sessionId); return cold !== undefined - ? { ...cold, inFlightTurn: null } - : { seq: 0, epoch: '', inFlightTurn: null }; + ? { ...cold, inFlightTurn: null, subagents: [] } + : { seq: 0, epoch: '', inFlightTurn: null, subagents: [] }; } await state.queue; return { seq: state.journal.seq, epoch: state.journal.epoch, inFlightTurn: state.tracker.get(sessionId), + subagents: state.roster.get(sessionId), }; } @@ -296,6 +301,7 @@ export class SessionEventBroadcaster { sessionId, journal, tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), activity, lastStatus: activity.status(), tail: [], @@ -330,6 +336,7 @@ export class SessionEventBroadcaster { sessionId: GLOBAL_SESSION_ID, journal, tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), tail: [], targets: new Map(), queue: Promise.resolve(), @@ -663,8 +670,11 @@ export class SessionEventBroadcaster { } private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise { - const { journal, tracker, tail, targets, sessionId } = state; + const { journal, tracker, roster, tail, targets, sessionId } = state; const annotation = tracker.apply(sessionId, event); + // Same queue-discipline as the in-flight tracker: snapshot rebuilds must + // see exactly the roster as of the durable watermark. + roster.apply(sessionId, event); let envelope: EventEnvelope; if (volatile) { diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts new file mode 100644 index 0000000000..c376f2bba4 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -0,0 +1,122 @@ +/** + * `SubagentRosterTracker` — accumulates the per-session roster of live + * subagent tasks so a reconnecting client can rebuild swarm cards from the + * session snapshot. The refresh flow subscribes at the snapshot watermark, so + * earlier `subagent.spawned` events — the only carriers of the swarm identity + * metadata — are never replayed to it. + * + * Ported from v1 (`packages/server/src/services/gateway/subagentRosterTracker.ts`), + * with one adaptation: the roster is dropped only on the MAIN agent's + * `turn.ended`. Unlike v1's firehose, every agent's events flow through the + * same per-session dispatch queue here, so a swarm member's own `turn.ended` + * must not wipe the roster while the swarm is still running. + * + * Without this roster a mid-swarm page refresh loses the swarm card's member + * list: REST `/tasks` only serves the main agent's background-task store + * (foreground swarm subagents never persist there), and later `subagent.*` + * events carry only the `subagentId`, so the identity metadata + * (`parentToolCallId` / `swarmIndex` / `description`) is unrecoverable until + * the swarm's `` tool output lands. + * + * Owned by the `SessionEventBroadcaster` and updated INSIDE its per-session + * dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster, + * the journal watermark, and the fan-out order mutually consistent. + * + * Lifetime: the roster is dropped on the main agent's `turn.ended`. After turn + * end the swarm's `` tool output is in the wire transcript + * and becomes the restore source; this also bounds the roster's lifetime + * (background subagents that outlive a turn are a known, pre-existing bound — + * same trade-off as `InFlightTurnTracker`). + */ + +import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol'; + +const MAIN_AGENT_ID = 'main'; + +export class SubagentRosterTracker { + private readonly bySession = new Map>(); + + apply(sessionId: string, event: Event): void { + switch (event.type) { + case 'subagent.spawned': { + let roster = this.bySession.get(sessionId); + if (!roster) { + roster = new Map(); + this.bySession.set(sessionId, roster); + } + roster.set(event.subagentId, { + id: event.subagentId, + session_id: sessionId, + kind: 'subagent', + description: event.description ?? event.subagentName ?? 'Sub Agent', + status: 'running', + subagent_phase: 'queued', + subagent_type: event.subagentName, + parent_tool_call_id: event.parentToolCallId === '' ? undefined : event.parentToolCallId, + swarm_index: event.swarmIndex, + run_in_background: event.runInBackground, + created_at: new Date().toISOString(), + }); + return; + } + case 'subagent.started': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'working'; + entry.suspended_reason = undefined; + // Keep an existing started_at: a resumed (previously suspended) + // subagent re-fires `subagent.started`. + entry.started_at ??= new Date().toISOString(); + return; + } + case 'subagent.suspended': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'suspended'; + entry.suspended_reason = event.reason; + return; + } + case 'subagent.completed': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'completed'; + entry.status = 'completed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview = event.resultSummary; + return; + } + case 'subagent.failed': { + const entry = this.bySession.get(sessionId)?.get(event.subagentId); + if (!entry) return; + entry.subagent_phase = 'failed'; + entry.status = 'failed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview = event.error; + return; + } + case 'turn.ended': { + // Only the main agent's turn end settles the swarm: after it the + // swarm's `` tool output is in the wire transcript + // and becomes the restore source. A subagent's own `turn.ended` flows + // through the same session queue and must not drop the roster mid-swarm. + if (event.agentId === MAIN_AGENT_ID) { + this.bySession.delete(sessionId); + } + return; + } + default: + return; + } + } + + /** Fresh copies — callers must not mutate the tracked entries. */ + get(sessionId: string): SnapshotSubagent[] { + const roster = this.bySession.get(sessionId); + if (!roster) return []; + return Array.from(roster.values(), (entry) => ({ ...entry })); + } + + clear(sessionId: string): void { + this.bySession.delete(sessionId); + } +} diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 79450c5a97..02f7611a49 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -391,6 +391,49 @@ describe('SessionEventBroadcaster', () => { expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' }); }); + it('getSnapshotState returns the live subagent roster until the main turn ends', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + const sub = lc.addAgent('agent-1'); + sessions.set('s1', lc); + await bc.subscribe('s1', collectingTarget().target); + + main.bus.emit(agentEvent('turn.started', { turnId: 1 })); + main.bus.emit( + agentEvent('subagent.spawned', { + subagentId: 'agent-1', + subagentName: 'kimi-subagent', + parentToolCallId: 'tc_swarm_1', + description: 'task agent-1', + swarmIndex: 0, + runInBackground: false, + }), + ); + main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent-1' })); + + const mid = await bc.getSnapshotState('s1'); + expect(mid.subagents).toEqual([ + expect.objectContaining({ + id: 'agent-1', + kind: 'subagent', + description: 'task agent-1', + subagent_phase: 'working', + parent_tool_call_id: 'tc_swarm_1', + swarm_index: 0, + run_in_background: false, + }), + ]); + + // A subagent's own turn.ended must not wipe the roster mid-swarm. + sub.bus.emit(agentEvent('turn.ended', { turnId: 2 })); + const still = await bc.getSnapshotState('s1'); + expect(still.subagents).toHaveLength(1); + + main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); + const ended = await bc.getSnapshotState('s1'); + expect(ended.subagents).toEqual([]); + }); + it('fans core model-catalog changes out to every session subscriber', async () => { const lc = new FakeLifecycle(); lc.addAgent('main'); diff --git a/packages/kap-server/test/snapshot.test.ts b/packages/kap-server/test/snapshot.test.ts index ad21a322e0..a96353fa7e 100644 --- a/packages/kap-server/test/snapshot.test.ts +++ b/packages/kap-server/test/snapshot.test.ts @@ -92,6 +92,20 @@ describe('server-v2 snapshot route enrichment', () => { thinking_text: '', running_tools: [], }, + subagents: [ + { + id: 'agent-1', + session_id: sessionId, + kind: 'subagent', + description: 'task agent-1', + status: 'running', + subagent_phase: 'working', + parent_tool_call_id: 'tc_swarm_1', + swarm_index: 0, + run_in_background: false, + created_at: new Date(now).toISOString(), + }, + ], }), }; @@ -142,6 +156,16 @@ describe('server-v2 snapshot route enrichment', () => { assistant_text: 'Hello', current_prompt_id: promptId, }); + expect(snap.subagents).toEqual([ + expect.objectContaining({ + id: 'agent-1', + kind: 'subagent', + subagent_phase: 'working', + parent_tool_call_id: 'tc_swarm_1', + swarm_index: 0, + run_in_background: false, + }), + ]); }); }); diff --git a/packages/kap-server/test/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index 03cef26f7b..29717fe19a 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -79,6 +79,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise { expect(snap.messages.items).toEqual([]); expect(snap.messages.has_more).toBe(false); expect(snap.in_flight_turn).toBeNull(); + expect(snap.subagents).toEqual([]); expect(snap.pending_approvals).toEqual([]); expect(snap.as_of_seq).toBe(0); expect(snap.epoch).toBe('ep_unit'); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts new file mode 100644 index 0000000000..b0fb58b447 --- /dev/null +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -0,0 +1,128 @@ +/** + * `SubagentRosterTracker` — live subagent roster for snapshot rebuilds. + */ + +import type { Event } from '@moonshot-ai/protocol'; +import { describe, expect, it } from 'vitest'; + +import { SubagentRosterTracker } from '../src/transport/ws/v1/subagentRosterTracker'; + +const SID = 'sess_1'; + +function ev(partial: Record): Event { + return { agentId: 'main', sessionId: SID, ...partial } as unknown as Event; +} + +function spawn(subagentId: string, extra: Record = {}): Event { + return ev({ + type: 'subagent.spawned', + subagentId, + subagentName: 'kimi-subagent', + parentToolCallId: 'tc_swarm_1', + description: `task ${subagentId}`, + swarmIndex: 0, + runInBackground: false, + ...extra, + }); +} + +describe('SubagentRosterTracker', () => { + it('seeds a roster entry from subagent.spawned with the swarm identity metadata', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1', { swarmIndex: 2 })); + + expect(t.get(SID)).toEqual([ + expect.objectContaining({ + id: 'agent-1', + session_id: SID, + kind: 'subagent', + description: 'task agent-1', + status: 'running', + subagent_phase: 'queued', + subagent_type: 'kimi-subagent', + parent_tool_call_id: 'tc_swarm_1', + swarm_index: 2, + run_in_background: false, + }), + ]); + }); + + it('treats an empty parentToolCallId as absent', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1', { parentToolCallId: '' })); + expect(t.get(SID)[0]?.parent_tool_call_id).toBeUndefined(); + }); + + it('follows the subagent phase transitions', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent-1' })); + expect(t.get(SID)[0]).toMatchObject({ subagent_phase: 'working' }); + expect(t.get(SID)[0]?.started_at).toBeDefined(); + + t.apply( + SID, + ev({ type: 'subagent.suspended', subagentId: 'agent-1', reason: 'rate limit' }), + ); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'suspended', + suspended_reason: 'rate limit', + }); + + // A resumed subagent re-fires started; the original started_at is kept. + const startedAt = t.get(SID)[0]?.started_at; + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent-1' })); + expect(t.get(SID)[0]).toMatchObject({ subagent_phase: 'working', started_at: startedAt }); + expect(t.get(SID)[0]?.suspended_reason).toBeUndefined(); + + t.apply( + SID, + ev({ type: 'subagent.completed', subagentId: 'agent-1', resultSummary: 'done' }), + ); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'completed', + status: 'completed', + output_preview: 'done', + }); + expect(t.get(SID)[0]?.completed_at).toBeDefined(); + }); + + it('marks failures with the error preview', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + t.apply(SID, ev({ type: 'subagent.failed', subagentId: 'agent-1', error: 'boom' })); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'failed', + status: 'failed', + output_preview: 'boom', + }); + }); + + it("clears the roster on the MAIN agent's turn.ended only", () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + + // A swarm member's own turn.ended flows through the same session queue and + // must not drop the roster mid-swarm. + t.apply(SID, ev({ type: 'turn.ended', agentId: 'agent-1', turnId: 1 })); + expect(t.get(SID)).toHaveLength(1); + + t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1 })); + expect(t.get(SID)).toEqual([]); + }); + + it('ignores lifecycle events for unknown subagents', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'ghost' })); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'x' })); + expect(t.get(SID)).toEqual([]); + }); + + it('returns fresh copies that callers cannot mutate back into the tracker', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + const first = t.get(SID); + first[0]!.description = 'mutated'; + expect(t.get(SID)[0]?.description).toBe('task agent-1'); + }); +}); From b280c94f43944211bf0a90ec8955443283062723 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 02:06:11 +0800 Subject: [PATCH 2/7] fix(kap-server): clear the subagent roster on the next main turn start --- .../transport/ws/v1/subagentRosterTracker.ts | 32 +++++++++++-------- .../test/sessionEventBroadcaster.test.ts | 11 +++++-- .../test/subagentRosterTracker.test.ts | 9 +++++- 3 files changed, 35 insertions(+), 17 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index c376f2bba4..c79cb99c4b 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -6,10 +6,12 @@ * metadata — are never replayed to it. * * Ported from v1 (`packages/server/src/services/gateway/subagentRosterTracker.ts`), - * with one adaptation: the roster is dropped only on the MAIN agent's - * `turn.ended`. Unlike v1's firehose, every agent's events flow through the - * same per-session dispatch queue here, so a swarm member's own `turn.ended` - * must not wipe the roster while the swarm is still running. + * with two adaptations: a swarm member's own `turn.ended` never clears the + * roster (every agent's events flow through the same per-session dispatch + * queue here, unlike v1's firehose), and the main agent's `turn.ended` does + * not clear it either — the swarm result is only queued for the async wire + * append at that point, so clearing there would open a window where a + * reconnecting client sees neither the roster nor the transcript result. * * Without this roster a mid-swarm page refresh loses the swarm card's member * list: REST `/tasks` only serves the main agent's background-task store @@ -22,11 +24,11 @@ * dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster, * the journal watermark, and the fan-out order mutually consistent. * - * Lifetime: the roster is dropped on the main agent's `turn.ended`. After turn - * end the swarm's `` tool output is in the wire transcript - * and becomes the restore source; this also bounds the roster's lifetime - * (background subagents that outlive a turn are a known, pre-existing bound — - * same trade-off as `InFlightTurnTracker`). + * Lifetime: the roster is dropped when the main agent starts its NEXT turn — + * by then the previous turn's `` tool output is durable in + * the wire transcript and takes over as the restore source. Background + * subagents that outlive a turn stay listed until that boundary (a known, + * pre-existing bound — same trade-off as `InFlightTurnTracker`). */ import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol'; @@ -94,11 +96,13 @@ export class SubagentRosterTracker { entry.output_preview = event.error; return; } - case 'turn.ended': { - // Only the main agent's turn end settles the swarm: after it the - // swarm's `` tool output is in the wire transcript - // and becomes the restore source. A subagent's own `turn.ended` flows - // through the same session queue and must not drop the roster mid-swarm. + case 'turn.started': { + // Settle the roster only when the main agent starts a NEW turn: by + // then the previous turn's swarm result is durable in the wire + // transcript (the restore source). Clearing at the main `turn.ended` + // would race the async wire append — a refresh in that window gets + // neither the live roster nor the transcript result. A subagent's own + // turn boundaries must never drop the roster mid-swarm. if (event.agentId === MAIN_AGENT_ID) { this.bySession.delete(sessionId); } diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 02f7611a49..ff9e113e13 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -391,7 +391,7 @@ describe('SessionEventBroadcaster', () => { expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' }); }); - it('getSnapshotState returns the live subagent roster until the main turn ends', async () => { + it('getSnapshotState returns the live subagent roster until the next main turn starts', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); const sub = lc.addAgent('agent-1'); @@ -429,9 +429,16 @@ describe('SessionEventBroadcaster', () => { const still = await bc.getSnapshotState('s1'); expect(still.subagents).toHaveLength(1); + // The main turn.ended keeps the roster too: the swarm result may not be + // durable in the wire transcript yet (async append). main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); const ended = await bc.getSnapshotState('s1'); - expect(ended.subagents).toEqual([]); + expect(ended.subagents).toHaveLength(1); + + // The next main turn.started settles the transcript — the roster is dropped. + main.bus.emit(agentEvent('turn.started', { turnId: 2 })); + const next = await bc.getSnapshotState('s1'); + expect(next.subagents).toEqual([]); }); it('fans core model-catalog changes out to every session subscriber', async () => { diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index b0fb58b447..d166e1d7b1 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -98,7 +98,7 @@ describe('SubagentRosterTracker', () => { }); }); - it("clears the roster on the MAIN agent's turn.ended only", () => { + it('clears the roster on the next MAIN turn.started, not on any turn.ended', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawn('agent-1')); @@ -107,7 +107,14 @@ describe('SubagentRosterTracker', () => { t.apply(SID, ev({ type: 'turn.ended', agentId: 'agent-1', turnId: 1 })); expect(t.get(SID)).toHaveLength(1); + // The main turn.ended must not drop the roster either: the swarm result + // may still be queued behind the async wire append, and a refresh in that + // window would otherwise lose the member list. t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1 })); + expect(t.get(SID)).toHaveLength(1); + + // The next main turn.started settles the previous transcript — safe to drop. + t.apply(SID, ev({ type: 'turn.started', agentId: 'main', turnId: 2 })); expect(t.get(SID)).toEqual([]); }); From 9614dc02fceef6edb044e44daf5a56b47b0f7406 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 02:15:48 +0800 Subject: [PATCH 3/7] fix(kap-server): exclude background subagents from the snapshot roster --- .../src/transport/ws/v1/subagentRosterTracker.ts | 11 +++++++++-- .../kap-server/test/subagentRosterTracker.test.ts | 6 ++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index c79cb99c4b..01db9c15df 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -27,8 +27,9 @@ * Lifetime: the roster is dropped when the main agent starts its NEXT turn — * by then the previous turn's `` tool output is durable in * the wire transcript and takes over as the restore source. Background - * subagents that outlive a turn stay listed until that boundary (a known, - * pre-existing bound — same trade-off as `InFlightTurnTracker`). + * subagents (`run_in_background`) are excluded by design: they persist in the + * background-task store and are served by REST `/tasks`, so listing them here + * would duplicate the row after a refresh. */ import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol'; @@ -41,6 +42,12 @@ export class SubagentRosterTracker { apply(sessionId: string, event: Event): void { switch (event.type) { case 'subagent.spawned': { + // Background subagents persist in the main agent's background-task + // store and come back through REST `/tasks` after a refresh (keyed by + // task id) — tracking them here too would duplicate the row (keyed by + // agent id) and mis-target cancel/detail actions. The roster exists + // for the foreground/live-only subagents REST cannot serve. + if (event.runInBackground === true) return; let roster = this.bySession.get(sessionId); if (!roster) { roster = new Map(); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index d166e1d7b1..8cb89c41c3 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -53,6 +53,12 @@ describe('SubagentRosterTracker', () => { expect(t.get(SID)[0]?.parent_tool_call_id).toBeUndefined(); }); + it('skips background subagents — REST /tasks already serves them after a refresh', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1', { runInBackground: true })); + expect(t.get(SID)).toEqual([]); + }); + it('follows the subagent phase transitions', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawn('agent-1')); From 604977c6e999ddc0d23789985502395aa8c99409 Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 02:25:13 +0800 Subject: [PATCH 4/7] fix(kap-server): finalize live roster entries when the main turn aborts --- .../transport/ws/v1/subagentRosterTracker.ts | 24 ++++++++++++++++++- .../test/sessionEventBroadcaster.test.ts | 2 +- .../test/subagentRosterTracker.test.ts | 24 ++++++++++++++++++- 3 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index 01db9c15df..e02ad2de4c 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -26,7 +26,10 @@ * * Lifetime: the roster is dropped when the main agent starts its NEXT turn — * by then the previous turn's `` tool output is durable in - * the wire transcript and takes over as the restore source. Background + * the wire transcript and takes over as the restore source. If the main turn + * aborts (cancelled / failed / blocked), still-live entries are finalized as + * failed at `turn.ended` instead: the swarm dies with the turn and the abort + * path suppresses the members' own `subagent.failed` events. Background * subagents (`run_in_background`) are excluded by design: they persist in the * background-task store and are served by REST `/tasks`, so listing them here * would duplicate the row after a refresh. @@ -103,6 +106,25 @@ export class SubagentRosterTracker { entry.output_preview = event.error; return; } + case 'turn.ended': { + if (event.agentId !== MAIN_AGENT_ID) return; + const roster = this.bySession.get(sessionId); + if (roster === undefined || event.reason === 'completed') return; + // Aborted main turn (cancelled / failed / blocked): the swarm dies + // with it, and the abort path suppresses the members' own + // `subagent.failed` events — finalize any still-live entries here so a + // refresh doesn't seed phantom `running` subagents that no later + // lifecycle event would correct. The roster itself stays until the + // next main `turn.started`, same as the completed path. + for (const entry of roster.values()) { + if (entry.status !== 'running') continue; + entry.status = 'failed'; + entry.subagent_phase = 'failed'; + entry.completed_at = new Date().toISOString(); + entry.output_preview ??= `Main turn ${event.reason}`; + } + return; + } case 'turn.started': { // Settle the roster only when the main agent starts a NEW turn: by // then the previous turn's swarm result is durable in the wire diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index ff9e113e13..3d1cc822fe 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -431,7 +431,7 @@ describe('SessionEventBroadcaster', () => { // The main turn.ended keeps the roster too: the swarm result may not be // durable in the wire transcript yet (async append). - main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); + main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); const ended = await bc.getSnapshotState('s1'); expect(ended.subagents).toHaveLength(1); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index 8cb89c41c3..841b528f2e 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -116,7 +116,7 @@ describe('SubagentRosterTracker', () => { // The main turn.ended must not drop the roster either: the swarm result // may still be queued behind the async wire append, and a refresh in that // window would otherwise lose the member list. - t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1 })); + t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'completed' })); expect(t.get(SID)).toHaveLength(1); // The next main turn.started settles the previous transcript — safe to drop. @@ -124,6 +124,28 @@ describe('SubagentRosterTracker', () => { expect(t.get(SID)).toEqual([]); }); + it('finalizes still-live entries when the main turn aborts', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + t.apply(SID, spawn('agent-2')); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'agent-2', resultSummary: 'done' })); + + // The abort path suppresses the members' own subagent.failed events, so + // the tracker must settle them itself. + t.apply(SID, ev({ type: 'turn.ended', agentId: 'main', turnId: 1, reason: 'cancelled' })); + + const entries = t.get(SID); + expect(entries[0]).toMatchObject({ + id: 'agent-1', + status: 'failed', + subagent_phase: 'failed', + output_preview: 'Main turn cancelled', + }); + expect(entries[0]?.completed_at).toBeDefined(); + // Already-terminal entries are left untouched. + expect(entries[1]).toMatchObject({ id: 'agent-2', status: 'completed', output_preview: 'done' }); + }); + it('ignores lifecycle events for unknown subagents', () => { const t = new SubagentRosterTracker(); t.apply(SID, ev({ type: 'subagent.started', subagentId: 'ghost' })); From dec5278f675b0280c313d283dafcf6616a9f7fbe Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 02:40:48 +0800 Subject: [PATCH 5/7] fix(kap-server): drop roster entries when foreground subagents detach --- .../transport/ws/v1/subagentRosterTracker.ts | 13 +++++++++ .../test/subagentRosterTracker.test.ts | 28 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index e02ad2de4c..d4fa3a941f 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -106,6 +106,19 @@ export class SubagentRosterTracker { entry.output_preview = event.error; return; } + case 'task.started': { + // A foreground subagent that detaches (Ctrl+B / timeout) re-enters as + // a detached background task served by REST `/tasks` under a new task + // id — drop its roster entry so a refresh doesn't seed both the roster + // row (agent id) and the REST row (task id). Registration of a + // background spawn emits the same event, but those were never tracked + // here, so the delete is a no-op for them. + const info = event.info; + if (info.kind === 'agent' && info.detached === true && info.agentId !== undefined) { + this.bySession.get(sessionId)?.delete(info.agentId); + } + return; + } case 'turn.ended': { if (event.agentId !== MAIN_AGENT_ID) return; const roster = this.bySession.get(sessionId); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index 841b528f2e..d56eda74e1 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -59,6 +59,34 @@ describe('SubagentRosterTracker', () => { expect(t.get(SID)).toEqual([]); }); + it('drops the entry when a foreground subagent detaches into a background task', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawn('agent-1')); + + const taskStarted = (detached: boolean): Event => + ev({ + type: 'task.started', + info: { + taskId: 'task_1', + kind: 'agent', + agentId: 'agent-1', + detached, + description: 'task agent-1', + status: 'running', + startedAt: 1, + endedAt: null, + }, + }); + + // A still-foreground registration (not detached) must keep the entry. + t.apply(SID, taskStarted(false)); + expect(t.get(SID)).toHaveLength(1); + + // The detach transition hands the subagent to REST /tasks — drop the row. + t.apply(SID, taskStarted(true)); + expect(t.get(SID)).toEqual([]); + }); + it('follows the subagent phase transitions', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawn('agent-1')); From e709de6d3a6479dad4433de83f35faac9908792b Mon Sep 17 00:00:00 2001 From: qer Date: Wed, 15 Jul 2026 12:01:58 +0800 Subject: [PATCH 6/7] fix(web): expand the swarm card by default while subagents are running --- .changeset/fix-web-swarm-card-auto-expand.md | 5 +++++ .../src/components/chat/tool-calls/SwarmTool.vue | 9 ++++++--- 2 files changed, 11 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-web-swarm-card-auto-expand.md diff --git a/.changeset/fix-web-swarm-card-auto-expand.md b/.changeset/fix-web-swarm-card-auto-expand.md new file mode 100644 index 0000000000..5244a9701e --- /dev/null +++ b/.changeset/fix-web-swarm-card-auto-expand.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Expand the AgentSwarm card by default while its subagents are still running. diff --git a/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue index 15e0ce2ad2..a970942cdc 100644 --- a/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue +++ b/apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue @@ -1,6 +1,7 @@