Skip to content

Commit b24a347

Browse files
authored
fix(kap-server): carry the live subagent roster in the session snapshot (#1719)
* fix(kap-server): carry the live subagent roster in the session snapshot * fix(kap-server): clear the subagent roster on the next main turn start * fix(kap-server): exclude background subagents from the snapshot roster * fix(kap-server): finalize live roster entries when the main turn aborts * fix(kap-server): drop roster entries when foreground subagents detach * fix(web): expand the swarm card by default while subagents are running * docs(kap-server): qualify the roster-clearing durability claim
1 parent 2591395 commit b24a347

11 files changed

Lines changed: 471 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Restore the AgentSwarm member list after a page refresh on the v2 backend.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Expand the AgentSwarm card by default while its subagents are still running.

apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<!-- apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue -->
22
<!-- A single AgentSwarm tool call, rendered as one inline "operation card".
3-
Defaults to collapsed; when opened the body shows a phase overview and a
3+
Expanded by default while the swarm runs, collapsed once settled; when
4+
opened the body shows a phase overview and a phase overview and a
45
per-member accordion — each subagent is a collapsible row (state dot +
56
name + one-line activity + phase) that expands on its own to reveal the
67
full output. While the swarm runs the rows come from the AppTask store
@@ -120,8 +121,10 @@ const segments = computed<Segment[]>(() =>
120121
),
121122
);
122123
123-
// Collapsed by default — §04 tool rows expand on demand.
124-
const open = ref(false);
124+
// Running swarms start expanded so live progress is visible without a click;
125+
// settled cards (history, finished runs) stay collapsed — §04 tool rows
126+
// expand on demand. The default applies only at mount; manual toggles stick.
127+
const open = ref(status.value === 'running' || inProgress.value > 0);
125128
function toggle(): void {
126129
open.value = !open.value;
127130
}

packages/kap-server/src/routes/snapshot.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ async function readViaLegacyAssembly(
201201
session,
202202
messages: { items, has_more: hasMore },
203203
in_flight_turn: inFlightTurn,
204+
subagents: snapState.subagents,
204205
pending_approvals: pendingApprovals,
205206
pending_questions: pendingQuestions,
206207
};

packages/kap-server/src/services/snapshot/snapshotReader.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ export class SnapshotReader implements ISnapshotReader {
148148
session,
149149
messages: { items, has_more: hasMore },
150150
in_flight_turn: inFlightTurn,
151+
subagents: snapState.subagents,
151152
pending_approvals: approvals,
152153
pending_questions: questions,
153154
};

packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,15 @@ import type {
6060
SessionCursor,
6161
SessionMetaUpdatedEvent,
6262
SessionStatus,
63+
SnapshotSubagent,
6364
} from '@moonshot-ai/protocol';
6465
import { isVolatileEventType } from '@moonshot-ai/protocol';
6566

6667
import { toWireApproval } from '../../../routes/approvals';
6768
import { toWireQuestion } from '../../../routes/questions';
6869
import { readLegacyStatus, toLegacyPhase } from '../../../services/legacyStatus/legacyStatus';
6970
import { InFlightTurnTracker } from './inFlightTurnTracker';
71+
import { SubagentRosterTracker } from './subagentRosterTracker';
7072
import {
7173
type EventEnvelope,
7274
type JournalLogger,
@@ -88,6 +90,7 @@ export interface SessionSnapshotState {
8890
seq: number;
8991
epoch: string;
9092
inFlightTurn: InFlightTurn | null;
93+
subagents: SnapshotSubagent[];
9194
}
9295

9396
/** A connection (or test double) that receives sequenced envelopes. */
@@ -107,6 +110,7 @@ interface SessionState {
107110
readonly sessionId: string;
108111
readonly journal: SessionEventJournal;
109112
readonly tracker: InFlightTurnTracker;
113+
readonly roster: SubagentRosterTracker;
110114
readonly activity?: ISessionActivity;
111115
/** Last status emitted (initialized from live session activity). */
112116
lastStatus?: SessionStatus;
@@ -233,14 +237,15 @@ export class SessionEventBroadcaster {
233237
if (state === undefined) {
234238
const cold = await this.readColdWatermark(sessionId);
235239
return cold !== undefined
236-
? { ...cold, inFlightTurn: null }
237-
: { seq: 0, epoch: '', inFlightTurn: null };
240+
? { ...cold, inFlightTurn: null, subagents: [] }
241+
: { seq: 0, epoch: '', inFlightTurn: null, subagents: [] };
238242
}
239243
await state.queue;
240244
return {
241245
seq: state.journal.seq,
242246
epoch: state.journal.epoch,
243247
inFlightTurn: state.tracker.get(sessionId),
248+
subagents: state.roster.get(sessionId),
244249
};
245250
}
246251

@@ -296,6 +301,7 @@ export class SessionEventBroadcaster {
296301
sessionId,
297302
journal,
298303
tracker: new InFlightTurnTracker(),
304+
roster: new SubagentRosterTracker(),
299305
activity,
300306
lastStatus: activity.status(),
301307
tail: [],
@@ -330,6 +336,7 @@ export class SessionEventBroadcaster {
330336
sessionId: GLOBAL_SESSION_ID,
331337
journal,
332338
tracker: new InFlightTurnTracker(),
339+
roster: new SubagentRosterTracker(),
333340
tail: [],
334341
targets: new Map(),
335342
queue: Promise.resolve(),
@@ -663,8 +670,11 @@ export class SessionEventBroadcaster {
663670
}
664671

665672
private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise<void> {
666-
const { journal, tracker, tail, targets, sessionId } = state;
673+
const { journal, tracker, roster, tail, targets, sessionId } = state;
667674
const annotation = tracker.apply(sessionId, event);
675+
// Same queue-discipline as the in-flight tracker: snapshot rebuilds must
676+
// see exactly the roster as of the durable watermark.
677+
roster.apply(sessionId, event);
668678

669679
let envelope: EventEnvelope;
670680
if (volatile) {
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* `SubagentRosterTracker` — accumulates the per-session roster of live
3+
* subagent tasks so a reconnecting client can rebuild swarm cards from the
4+
* session snapshot. The refresh flow subscribes at the snapshot watermark, so
5+
* earlier `subagent.spawned` events — the only carriers of the swarm identity
6+
* metadata — are never replayed to it.
7+
*
8+
* Ported from v1 (`packages/server/src/services/gateway/subagentRosterTracker.ts`),
9+
* with two adaptations: a swarm member's own `turn.ended` never clears the
10+
* roster (every agent's events flow through the same per-session dispatch
11+
* queue here, unlike v1's firehose), and the main agent's `turn.ended` does
12+
* not clear it either — the swarm result is only queued for the async wire
13+
* append at that point, so clearing there would open a window where a
14+
* reconnecting client sees neither the roster nor the transcript result.
15+
*
16+
* Without this roster a mid-swarm page refresh loses the swarm card's member
17+
* list: REST `/tasks` only serves the main agent's background-task store
18+
* (foreground swarm subagents never persist there), and later `subagent.*`
19+
* events carry only the `subagentId`, so the identity metadata
20+
* (`parentToolCallId` / `swarmIndex` / `description`) is unrecoverable until
21+
* the swarm's `<agent_swarm_result>` tool output lands.
22+
*
23+
* Owned by the `SessionEventBroadcaster` and updated INSIDE its per-session
24+
* dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster,
25+
* the journal watermark, and the fan-out order mutually consistent.
26+
*
27+
* Lifetime: the roster is dropped when the main agent starts its NEXT turn —
28+
* the previous turn's result record was queued for the async wire append
29+
* before `turn.ended`, so by then it is durable in practice and the
30+
* transcript takes over as the restore source (a queued/cron follow-up turn
31+
* can still start inside the ms-scale flush gap; that window self-heals on
32+
* the next refresh). If the main turn
33+
* aborts (cancelled / failed / blocked), still-live entries are finalized as
34+
* failed at `turn.ended` instead: the swarm dies with the turn and the abort
35+
* path suppresses the members' own `subagent.failed` events. Background
36+
* subagents (`run_in_background`) are excluded by design: they persist in the
37+
* background-task store and are served by REST `/tasks`, so listing them here
38+
* would duplicate the row after a refresh.
39+
*/
40+
41+
import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol';
42+
43+
const MAIN_AGENT_ID = 'main';
44+
45+
export class SubagentRosterTracker {
46+
private readonly bySession = new Map<string, Map<string, SnapshotSubagent>>();
47+
48+
apply(sessionId: string, event: Event): void {
49+
switch (event.type) {
50+
case 'subagent.spawned': {
51+
// Background subagents persist in the main agent's background-task
52+
// store and come back through REST `/tasks` after a refresh (keyed by
53+
// task id) — tracking them here too would duplicate the row (keyed by
54+
// agent id) and mis-target cancel/detail actions. The roster exists
55+
// for the foreground/live-only subagents REST cannot serve.
56+
if (event.runInBackground === true) return;
57+
let roster = this.bySession.get(sessionId);
58+
if (!roster) {
59+
roster = new Map();
60+
this.bySession.set(sessionId, roster);
61+
}
62+
roster.set(event.subagentId, {
63+
id: event.subagentId,
64+
session_id: sessionId,
65+
kind: 'subagent',
66+
description: event.description ?? event.subagentName ?? 'Sub Agent',
67+
status: 'running',
68+
subagent_phase: 'queued',
69+
subagent_type: event.subagentName,
70+
parent_tool_call_id: event.parentToolCallId === '' ? undefined : event.parentToolCallId,
71+
swarm_index: event.swarmIndex,
72+
run_in_background: event.runInBackground,
73+
created_at: new Date().toISOString(),
74+
});
75+
return;
76+
}
77+
case 'subagent.started': {
78+
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
79+
if (!entry) return;
80+
entry.subagent_phase = 'working';
81+
entry.suspended_reason = undefined;
82+
// Keep an existing started_at: a resumed (previously suspended)
83+
// subagent re-fires `subagent.started`.
84+
entry.started_at ??= new Date().toISOString();
85+
return;
86+
}
87+
case 'subagent.suspended': {
88+
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
89+
if (!entry) return;
90+
entry.subagent_phase = 'suspended';
91+
entry.suspended_reason = event.reason;
92+
return;
93+
}
94+
case 'subagent.completed': {
95+
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
96+
if (!entry) return;
97+
entry.subagent_phase = 'completed';
98+
entry.status = 'completed';
99+
entry.completed_at = new Date().toISOString();
100+
entry.output_preview = event.resultSummary;
101+
return;
102+
}
103+
case 'subagent.failed': {
104+
const entry = this.bySession.get(sessionId)?.get(event.subagentId);
105+
if (!entry) return;
106+
entry.subagent_phase = 'failed';
107+
entry.status = 'failed';
108+
entry.completed_at = new Date().toISOString();
109+
entry.output_preview = event.error;
110+
return;
111+
}
112+
case 'task.started': {
113+
// A foreground subagent that detaches (Ctrl+B / timeout) re-enters as
114+
// a detached background task served by REST `/tasks` under a new task
115+
// id — drop its roster entry so a refresh doesn't seed both the roster
116+
// row (agent id) and the REST row (task id). Registration of a
117+
// background spawn emits the same event, but those were never tracked
118+
// here, so the delete is a no-op for them.
119+
const info = event.info;
120+
if (info.kind === 'agent' && info.detached === true && info.agentId !== undefined) {
121+
this.bySession.get(sessionId)?.delete(info.agentId);
122+
}
123+
return;
124+
}
125+
case 'turn.ended': {
126+
if (event.agentId !== MAIN_AGENT_ID) return;
127+
const roster = this.bySession.get(sessionId);
128+
if (roster === undefined || event.reason === 'completed') return;
129+
// Aborted main turn (cancelled / failed / blocked): the swarm dies
130+
// with it, and the abort path suppresses the members' own
131+
// `subagent.failed` events — finalize any still-live entries here so a
132+
// refresh doesn't seed phantom `running` subagents that no later
133+
// lifecycle event would correct. The roster itself stays until the
134+
// next main `turn.started`, same as the completed path.
135+
for (const entry of roster.values()) {
136+
if (entry.status !== 'running') continue;
137+
entry.status = 'failed';
138+
entry.subagent_phase = 'failed';
139+
entry.completed_at = new Date().toISOString();
140+
entry.output_preview ??= `Main turn ${event.reason}`;
141+
}
142+
return;
143+
}
144+
case 'turn.started': {
145+
// Settle the roster when the main agent starts a NEW turn. The result
146+
// record is queued for the async wire append before `turn.ended`, so
147+
// by the next turn it is durable in practice; a queued/cron follow-up
148+
// can still start inside the flush gap, but that window is ms-scale
149+
// and self-heals on the next refresh once the flush lands. (Fully
150+
// closing it needs the snapshot reader to read through the agent
151+
// append log — deliberately left out of this change.) A subagent's
152+
// own turn boundaries must never drop the roster mid-swarm.
153+
if (event.agentId === MAIN_AGENT_ID) {
154+
this.bySession.delete(sessionId);
155+
}
156+
return;
157+
}
158+
default:
159+
return;
160+
}
161+
}
162+
163+
/** Fresh copies — callers must not mutate the tracked entries. */
164+
get(sessionId: string): SnapshotSubagent[] {
165+
const roster = this.bySession.get(sessionId);
166+
if (!roster) return [];
167+
return Array.from(roster.values(), (entry) => ({ ...entry }));
168+
}
169+
170+
clear(sessionId: string): void {
171+
this.bySession.delete(sessionId);
172+
}
173+
}

packages/kap-server/test/sessionEventBroadcaster.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,56 @@ describe('SessionEventBroadcaster', () => {
391391
expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' });
392392
});
393393

394+
it('getSnapshotState returns the live subagent roster until the next main turn starts', async () => {
395+
const lc = new FakeLifecycle();
396+
const main = lc.addAgent('main');
397+
const sub = lc.addAgent('agent-1');
398+
sessions.set('s1', lc);
399+
await bc.subscribe('s1', collectingTarget().target);
400+
401+
main.bus.emit(agentEvent('turn.started', { turnId: 1 }));
402+
main.bus.emit(
403+
agentEvent('subagent.spawned', {
404+
subagentId: 'agent-1',
405+
subagentName: 'kimi-subagent',
406+
parentToolCallId: 'tc_swarm_1',
407+
description: 'task agent-1',
408+
swarmIndex: 0,
409+
runInBackground: false,
410+
}),
411+
);
412+
main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent-1' }));
413+
414+
const mid = await bc.getSnapshotState('s1');
415+
expect(mid.subagents).toEqual([
416+
expect.objectContaining({
417+
id: 'agent-1',
418+
kind: 'subagent',
419+
description: 'task agent-1',
420+
subagent_phase: 'working',
421+
parent_tool_call_id: 'tc_swarm_1',
422+
swarm_index: 0,
423+
run_in_background: false,
424+
}),
425+
]);
426+
427+
// A subagent's own turn.ended must not wipe the roster mid-swarm.
428+
sub.bus.emit(agentEvent('turn.ended', { turnId: 2 }));
429+
const still = await bc.getSnapshotState('s1');
430+
expect(still.subagents).toHaveLength(1);
431+
432+
// The main turn.ended keeps the roster too: the swarm result may not be
433+
// durable in the wire transcript yet (async append).
434+
main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' }));
435+
const ended = await bc.getSnapshotState('s1');
436+
expect(ended.subagents).toHaveLength(1);
437+
438+
// The next main turn.started settles the transcript — the roster is dropped.
439+
main.bus.emit(agentEvent('turn.started', { turnId: 2 }));
440+
const next = await bc.getSnapshotState('s1');
441+
expect(next.subagents).toEqual([]);
442+
});
443+
394444
it('fans core model-catalog changes out to every session subscriber', async () => {
395445
const lc = new FakeLifecycle();
396446
lc.addAgent('main');

packages/kap-server/test/snapshot.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,20 @@ describe('server-v2 snapshot route enrichment', () => {
9292
thinking_text: '',
9393
running_tools: [],
9494
},
95+
subagents: [
96+
{
97+
id: 'agent-1',
98+
session_id: sessionId,
99+
kind: 'subagent',
100+
description: 'task agent-1',
101+
status: 'running',
102+
subagent_phase: 'working',
103+
parent_tool_call_id: 'tc_swarm_1',
104+
swarm_index: 0,
105+
run_in_background: false,
106+
created_at: new Date(now).toISOString(),
107+
},
108+
],
95109
}),
96110
};
97111

@@ -142,6 +156,16 @@ describe('server-v2 snapshot route enrichment', () => {
142156
assistant_text: 'Hello',
143157
current_prompt_id: promptId,
144158
});
159+
expect(snap.subagents).toEqual([
160+
expect.objectContaining({
161+
id: 'agent-1',
162+
kind: 'subagent',
163+
subagent_phase: 'working',
164+
parent_tool_call_id: 'tc_swarm_1',
165+
swarm_index: 0,
166+
run_in_background: false,
167+
}),
168+
]);
145169
});
146170
});
147171

0 commit comments

Comments
 (0)