Skip to content
5 changes: 5 additions & 0 deletions .changeset/fix-kap-server-snapshot-roster.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Restore the AgentSwarm member list after a page refresh on the v2 backend.
5 changes: 5 additions & 0 deletions .changeset/fix-web-swarm-card-auto-expand.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Expand the AgentSwarm card by default while its subagents are still running.
9 changes: 6 additions & 3 deletions apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<!-- apps/kimi-web/src/components/chat/tool-calls/SwarmTool.vue -->
<!-- A single AgentSwarm tool call, rendered as one inline "operation card".
Defaults to collapsed; when opened the body shows a phase overview and a
Expanded by default while the swarm runs, collapsed once settled; when
opened the body shows a phase overview and a phase overview and a
per-member accordion — each subagent is a collapsible row (state dot +
name + one-line activity + phase) that expands on its own to reveal the
full output. While the swarm runs the rows come from the AppTask store
Expand Down Expand Up @@ -120,8 +121,10 @@ const segments = computed<Segment[]>(() =>
),
);

// Collapsed by default — §04 tool rows expand on demand.
const open = ref(false);
// Running swarms start expanded so live progress is visible without a click;
// settled cards (history, finished runs) stay collapsed — §04 tool rows
// expand on demand. The default applies only at mount; manual toggles stick.
const open = ref(status.value === 'running' || inProgress.value > 0);
function toggle(): void {
open.value = !open.value;
}
Expand Down
1 change: 1 addition & 0 deletions packages/kap-server/src/routes/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down
16 changes: 13 additions & 3 deletions packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,15 @@ import type {
SessionCursor,
SessionMetaUpdatedEvent,
SessionStatus,
SnapshotSubagent,
} from '@moonshot-ai/protocol';
import { isVolatileEventType } from '@moonshot-ai/protocol';

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,
Expand All @@ -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. */
Expand All @@ -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;
Expand Down Expand Up @@ -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),
};
}

Expand Down Expand Up @@ -296,6 +301,7 @@ export class SessionEventBroadcaster {
sessionId,
journal,
tracker: new InFlightTurnTracker(),
roster: new SubagentRosterTracker(),
activity,
lastStatus: activity.status(),
tail: [],
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -663,8 +670,11 @@ export class SessionEventBroadcaster {
}

private async dispatch(state: SessionState, event: Event, volatile: boolean): Promise<void> {
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) {
Expand Down
173 changes: 173 additions & 0 deletions packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* `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 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
* (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 `<agent_swarm_result>` 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 when the main agent starts its NEXT turn —
* the previous turn's result record was queued for the async wire append
* before `turn.ended`, so by then it is durable in practice and the
* transcript takes over as the restore source (a queued/cron follow-up turn
* can still start inside the ms-scale flush gap; that window self-heals on
* the next refresh). 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.
*/

import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol';

const MAIN_AGENT_ID = 'main';

export class SubagentRosterTracker {
private readonly bySession = new Map<string, Map<string, SnapshotSubagent>>();

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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Drop roster entries once foreground subagents detach

This only excludes subagents that were launched with run_in_background=true, but a foreground Agent run can later be detached (Ctrl+B/timeout): AgentTool.execution waits for waitForForegroundRelease, and TaskService.detachEntry re-emits it as a detached task that /tasks serves under the generated task id. Until the next main turn starts, the snapshot still carries the original agent-id roster entry, so refreshing in that detached-but-same-turn window seeds both the roster item and the REST task, duplicating the subagent and mis-targeting detail/cancel actions; remove or update the roster when the matching detached task.started event arrives.

Useful? React with 👍 / 👎.

let roster = this.bySession.get(sessionId);
if (!roster) {
roster = new Map();
this.bySession.set(sessionId, roster);
}
roster.set(event.subagentId, {
id: event.subagentId,
Comment on lines +62 to +63

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude background subagents from the snapshot roster

When an Agent call uses run_in_background=true, the core emits subagent.spawned with subagentId set to the child agent id, then registers a separate background task whose /tasks id is generated by AgentTaskService.registerTask. Storing those background spawns in the snapshot roster means a refreshed web client gets one synthetic task keyed by the agent id from snap.subagents and another real task keyed by the task id from REST, so the subagent dock can show duplicate rows and cancellation/detail actions can target the non-existent synthetic task. The roster should be limited to foreground/live-only subagents that REST cannot return, or keyed to the real task id for background runs.

Useful? React with 👍 / 👎.

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 '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);
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': {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Finalize roster entries when the main turn aborts

When the main turn is cancelled/failed/blocked before spawned foreground subagents finish, this tracker keeps their last running entries until a future main turn.started. In that abort path mirrorAgentRun suppresses subagent.failed for aborts (packages/agent-core-v2/src/session/subagent/mirrorAgentRun.ts:135), and the broadcaster maps those main turn.ended reasons to an aborted session, so a page refresh after pressing Stop seeds the snapshot with still-running subagents even though no later lifecycle event may correct them. Please mark or clear the roster on terminal main turn.ended reasons instead of waiting only for the next turn.

Useful? React with 👍 / 👎.

// Settle the roster when the main agent starts a NEW turn. The result
// record is queued for the async wire append before `turn.ended`, so
// by the next turn it is durable in practice; a queued/cron follow-up
// can still start inside the flush gap, but that window is ms-scale
// and self-heals on the next refresh once the flush lands. (Fully
// closing it needs the snapshot reader to read through the agent
// append log — deliberately left out of this change.) A subagent's
// own turn boundaries must never drop the roster mid-swarm.
if (event.agentId === MAIN_AGENT_ID) {
this.bySession.delete(sessionId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don't clear the roster before the transcript is durable

When a user refreshes immediately after the main turn.ended is broadcast but before the async wire append has flushed the final AgentSwarm tool result, this deletion makes /snapshot return neither the live roster nor the transcript result. The default SnapshotReader reads wire.jsonl directly in packages/kap-server/src/services/snapshot/snapshotReader.ts:100-103/:302-303, while the append log only queues a flush in packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts:50-56; since the client then subscribes at the turn.ended seq, the earlier subagent.spawned events are not replayed and the swarm member list can still disappear in that race window.

Useful? React with 👍 / 👎.

Comment on lines +153 to +154

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve aborted swarm roster past next turn

When the previous main turn ended as cancelled, failed, or blocked, the block above synthesizes failed subagent entries because the member subagent.failed events may never arrive/replay. Deleting the entire session roster on the next main turn.started drops that only snapshot source, so if the user starts another prompt and then refreshes, the snapshot contains no subagents and the interrupted swarm card loses its member rows/states again.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the roster until the transcript is durable

When a reconnect/resync happens just after the main agent starts a follow-up turn, this delete can make the snapshot contain neither the live roster nor the previous swarm result. SnapshotReader reads wire.jsonl directly and in parallel with getSnapshotState, while the agent wire append path is asynchronous, so there is no guarantee that the <agent_swarm_result> this comment relies on has reached disk before the roster is cleared. Keep the roster until the disk transcript has observed the result (or make the snapshot reader flush/read through the append log) to avoid losing the member list in that window.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point in the absolute sense — turn.started does not establish a happens-before with the async append flush. We accept the trade-off deliberately:

  1. The result record is queued for the append before turn.ended, so the residual race is limited to immediately-followed turns (queued/cron prompts) — a ms-scale window, versus the minutes-long member-list loss this tracker exists to fix.
  2. The failure inside that window is transient: once the flush lands, the next refresh restores the member list from the transcript.

Fully closing it needs the snapshot reader to read through (or flush) the agent append log, which spans agent-core-v2 persistence and the kap-server reader — intentionally out of scope for this PR. Also qualified the over-strong 'durable' claim in the tracker comment in 50da3ea to say exactly this.

}
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);
}
}
50 changes: 50 additions & 0 deletions packages/kap-server/test/sessionEventBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,56 @@ describe('SessionEventBroadcaster', () => {
expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' });
});

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');
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);

// 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, reason: 'completed' }));
const ended = await bc.getSnapshotState('s1');
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 () => {
const lc = new FakeLifecycle();
lc.addAgent('main');
Expand Down
24 changes: 24 additions & 0 deletions packages/kap-server/test/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
],
}),
};

Expand Down Expand Up @@ -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,
}),
]);
});
});

Expand Down
Loading
Loading