From 02171122fb52b7a6f796609a1365f597ad7f32c5 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 13 Jul 2026 21:21:36 +0800 Subject: [PATCH 1/4] fix(kap-server): carry the live subagent roster in the session snapshot Accumulate the per-session live subagent roster in the broadcaster's dispatch queue and expose it via getSnapshotState, so the web client can rebuild swarm cards after a page refresh. Both snapshot assembly paths (SnapshotReader and the legacy resume path) pass it through as the optional subagents field. --- .changeset/kap-server-subagent-roster.md | 5 + packages/kap-server/src/routes/snapshot.ts | 1 + .../src/services/snapshot/snapshotReader.ts | 1 + .../ws/v1/sessionEventBroadcaster.ts | 17 ++- .../transport/ws/v1/subagentRosterTracker.ts | 106 +++++++++++++++ .../test/sessionEventBroadcaster.test.ts | 40 ++++++ .../test/snapshotReader.unit.test.ts | 28 +++- .../test/subagentRosterTracker.test.ts | 126 ++++++++++++++++++ 8 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 .changeset/kap-server-subagent-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/kap-server-subagent-roster.md b/.changeset/kap-server-subagent-roster.md new file mode 100644 index 0000000000..1b58bbc4a6 --- /dev/null +++ b/.changeset/kap-server-subagent-roster.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Carry the live subagent roster in the v2 server session snapshot, so swarm member lists survive a page refresh on that backend. diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index 13ce2eecf1..a082b00a0a 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 91fa0a5e13..05ad26ba12 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -138,6 +138,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 6025c4d1f2..b64e36c3ba 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -61,6 +61,7 @@ import type { SessionCursor, SessionMetaUpdatedEvent, SessionStatus, + SnapshotSubagent, } from '@moonshot-ai/protocol'; import { isVolatileEventType } from '@moonshot-ai/protocol'; @@ -78,6 +79,7 @@ import { SessionEventJournal, sessionJournalPath, } from './sessionEventJournal'; +import { SubagentRosterTracker } from './subagentRosterTracker'; export type ResyncReason = 'buffer_overflow' | 'session_recreated' | 'epoch_changed'; @@ -93,6 +95,8 @@ export interface SessionSnapshotState { seq: number; epoch: string; inFlightTurn: InFlightTurn | null; + /** Live subagent roster at the watermark (see `SubagentRosterTracker`). */ + subagents: SnapshotSubagent[]; } /** A connection (or test double) that receives sequenced envelopes. */ @@ -112,6 +116,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; @@ -238,14 +243,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), }; } @@ -301,6 +307,7 @@ export class SessionEventBroadcaster { sessionId, journal, tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), activity, lastStatus: activity.status(), tail: [], @@ -335,6 +342,7 @@ export class SessionEventBroadcaster { sessionId: GLOBAL_SESSION_ID, journal, tracker: new InFlightTurnTracker(), + roster: new SubagentRosterTracker(), tail: [], targets: new Map(), queue: Promise.resolve(), @@ -639,8 +647,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 for the subagent roster: 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..622303b711 --- /dev/null +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -0,0 +1,106 @@ +/** + * `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. + * + * Owned by the `SessionEventBroadcaster` and updated inside its per-session + * dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster, + * the journal watermark, and fan-out order mutually consistent. + * + * Lifetime: the roster is dropped on `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'; + +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', + ...(event.subagentName !== undefined ? { subagent_type: event.subagentName } : {}), + ...(event.parentToolCallId !== undefined + ? { parent_tool_call_id: event.parentToolCallId } + : {}), + ...(event.swarmIndex !== undefined ? { 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': { + // After turn end the swarm's `` tool output is in + // the wire transcript and becomes the restore source; dropping the + // roster here also bounds its lifetime. + 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 e96e9c133f..1dbf0a4580 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -293,6 +293,46 @@ describe('SessionEventBroadcaster', () => { expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' }); }); + it('getSnapshotState exposes the live subagent roster until turn.ended', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + sessions.set('s1', lc); + await bc.subscribe('s1', collectingTarget().target); + + main.bus.emit( + agentEvent('subagent.spawned', { + subagentId: 'agent_1', + subagentName: 'explore', + parentToolCallId: 'call_1', + description: 'explore the auth flow', + swarmIndex: 0, + runInBackground: false, + }), + ); + main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_1' })); + const mid = await bc.getSnapshotState('s1'); + expect(mid.subagents).toMatchObject([ + { + id: 'agent_1', + session_id: 's1', + kind: 'subagent', + description: 'explore the auth flow', + status: 'running', + subagent_phase: 'working', + subagent_type: 'explore', + parent_tool_call_id: 'call_1', + swarm_index: 0, + run_in_background: false, + }, + ]); + + main.bus.emit(agentEvent('subagent.completed', { subagentId: 'agent_1', resultSummary: 'done' })); + main.bus.emit(agentEvent('turn.started', { turnId: 1 })); + main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); + const after = await bc.getSnapshotState('s1'); + expect(after.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/snapshotReader.unit.test.ts b/packages/kap-server/test/snapshotReader.unit.test.ts index c357bb3f7a..dbd3481e5c 100644 --- a/packages/kap-server/test/snapshotReader.unit.test.ts +++ b/packages/kap-server/test/snapshotReader.unit.test.ts @@ -50,7 +50,7 @@ interface Fixture { sessionDir: (sid: string) => string; index: Map; reader: SnapshotReader; - broadcaster: { seq: number; epoch: string; inFlightTurn: unknown }; + broadcaster: { seq: number; epoch: string; inFlightTurn: unknown; subagents: unknown[] }; } const tmpDirs: string[] = []; @@ -70,7 +70,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise undefined }], ]), }; - const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null }; + const broadcaster = { seq: 0, epoch: 'ep_unit', inFlightTurn: null, subagents: [] as unknown[] }; const deps: SnapshotReaderDeps = { homeDir, core: core as never, @@ -79,6 +79,7 @@ async function makeFixtureAsync(opts?: { cacheLimit?: number }): Promise { expect(snap.messages.items).toHaveLength(1); expect((snap.messages.items[0]!.content[0] as { text: string }).text).toBe('only-one'); }); + + it('passes the broadcast subagent roster through to the response', async () => { + const f = await makeFixtureAsync(); + const sid = 'sess_roster'; + await seedSession(f, sid); + f.broadcaster.subagents = [ + { + id: 'agent_1', + session_id: sid, + kind: 'subagent', + description: 'explore the auth flow', + status: 'running', + created_at: new Date().toISOString(), + subagent_phase: 'working', + swarm_index: 0, + run_in_background: false, + }, + ]; + const snap = await f.reader.read(sid); + expect(snap.subagents).toMatchObject([ + { id: 'agent_1', subagent_phase: 'working', swarm_index: 0, run_in_background: false }, + ]); + }); }); describe('readWireRecords', () => { diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts new file mode 100644 index 0000000000..0f8f815388 --- /dev/null +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -0,0 +1,126 @@ +/** + * `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 spawned(overrides: Record = {}): Event { + return ev({ + type: 'subagent.spawned', + subagentId: 'agent_1', + subagentName: 'explore', + parentToolCallId: 'call_1', + description: 'explore the auth flow', + swarmIndex: 0, + runInBackground: false, + ...overrides, + }); +} + +describe('SubagentRosterTracker', () => { + it('records the full swarm identity on spawn', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + expect(t.get(SID)).toMatchObject([ + { + id: 'agent_1', + session_id: SID, + kind: 'subagent', + description: 'explore the auth flow', + status: 'running', + subagent_phase: 'queued', + subagent_type: 'explore', + parent_tool_call_id: 'call_1', + swarm_index: 0, + run_in_background: false, + }, + ]); + expect(t.get(SID)[0]?.created_at).toBeDefined(); + }); + + it('ignores lifecycle events for unknown subagent ids', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'x' })); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'ghost' })); + t.apply(SID, ev({ type: 'subagent.suspended', subagentId: 'ghost', reason: 'approval' })); + expect(t.get(SID)).toEqual([]); + }); + + it('tracks suspend and resume, keeping the original started_at', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_1' })); + const startedAt = t.get(SID)[0]?.started_at; + expect(startedAt).toBeDefined(); + + t.apply(SID, ev({ type: 'subagent.suspended', subagentId: 'agent_1', reason: 'awaiting approval' })); + expect(t.get(SID)[0]).toMatchObject({ + subagent_phase: 'suspended', + suspended_reason: 'awaiting approval', + }); + + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_1' })); + const resumed = t.get(SID)[0]!; + expect(resumed.subagent_phase).toBe('working'); + expect(resumed.started_at).toBe(startedAt); + expect(resumed.suspended_reason).toBeUndefined(); + }); + + it('records completion with the result summary as output preview', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + 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('records failure with the error as output preview', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + 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('drops the roster on turn.ended', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'turn.ended', turnId: 1 })); + expect(t.get(SID)).toEqual([]); + }); + + it('returns fresh copies that do not alias the tracked entries', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + const first = t.get(SID); + first[0]!.status = 'failed'; + first.push({} as never); + const second = t.get(SID); + expect(second).toHaveLength(1); + expect(second[0]?.status).toBe('running'); + }); + + it('clear drops the roster', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + expect(t.get(SID)).toHaveLength(1); + t.clear(SID); + expect(t.get(SID)).toEqual([]); + }); +}); From 2b55fc120d74080b66f6a57e7c877d44565e2584 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 03:56:13 +0800 Subject: [PATCH 2/4] fix(kap-server): keep roster through child turn completion --- .changeset/kap-server-subagent-roster.md | 2 +- .../transport/ws/v1/subagentRosterTracker.ts | 20 ++++---- .../test/sessionEventBroadcaster.test.ts | 47 +++++++++++++++++-- .../test/subagentRosterTracker.test.ts | 15 +++++- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/.changeset/kap-server-subagent-roster.md b/.changeset/kap-server-subagent-roster.md index 1b58bbc4a6..ea61c5e075 100644 --- a/.changeset/kap-server-subagent-roster.md +++ b/.changeset/kap-server-subagent-roster.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Carry the live subagent roster in the v2 server session snapshot, so swarm member lists survive a page refresh on that backend. +web: Fix swarm member lists disappearing after a page refresh on the v2 backend. diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index 622303b711..5098871ddc 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -9,13 +9,14 @@ * dispatch queue — same pattern as `InFlightTurnTracker`, keeping the roster, * the journal watermark, and fan-out order mutually consistent. * - * Lifetime: the roster is dropped on `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 on the main agent's `turn.ended`. After the + * main turn ends, 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 { MAIN_AGENT_ID } from '@moonshot-ai/agent-core-v2'; import type { Event, SnapshotSubagent } from '@moonshot-ai/protocol'; export class SubagentRosterTracker { @@ -36,11 +37,9 @@ export class SubagentRosterTracker { description: event.description ?? event.subagentName ?? 'Sub Agent', status: 'running', subagent_phase: 'queued', - ...(event.subagentName !== undefined ? { subagent_type: event.subagentName } : {}), - ...(event.parentToolCallId !== undefined - ? { parent_tool_call_id: event.parentToolCallId } - : {}), - ...(event.swarmIndex !== undefined ? { swarm_index: event.swarmIndex } : {}), + subagent_type: event.subagentName, + parent_tool_call_id: event.parentToolCallId, + swarm_index: event.swarmIndex, run_in_background: event.runInBackground, created_at: new Date().toISOString(), }); @@ -82,6 +81,7 @@ export class SubagentRosterTracker { return; } case 'turn.ended': { + if (event.agentId !== MAIN_AGENT_ID) return; // After turn end the swarm's `` tool output is in // the wire transcript and becomes the restore source; dropping the // roster here also bounds its lifetime. diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 1dbf0a4580..3e1356413a 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -293,9 +293,10 @@ describe('SessionEventBroadcaster', () => { expect(snap.inFlightTurn).toMatchObject({ turn_id: 1, assistant_text: 'Hello' }); }); - it('getSnapshotState exposes the live subagent roster until turn.ended', async () => { + it('getSnapshotState keeps the multi-subagent roster when one child turn ends', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); + const firstChild = lc.addAgent('agent_1'); sessions.set('s1', lc); await bc.subscribe('s1', collectingTarget().target); @@ -309,7 +310,20 @@ describe('SessionEventBroadcaster', () => { runInBackground: false, }), ); + main.bus.emit( + agentEvent('subagent.spawned', { + subagentId: 'agent_2', + subagentName: 'explore', + parentToolCallId: 'call_1', + description: 'inspect the cancellation path', + swarmIndex: 1, + runInBackground: false, + }), + ); main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_1' })); + main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_2' })); + firstChild.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); + main.bus.emit(agentEvent('subagent.completed', { subagentId: 'agent_1', resultSummary: 'done' })); const mid = await bc.getSnapshotState('s1'); expect(mid.subagents).toMatchObject([ { @@ -317,18 +331,43 @@ describe('SessionEventBroadcaster', () => { session_id: 's1', kind: 'subagent', description: 'explore the auth flow', + status: 'completed', + subagent_phase: 'completed', + subagent_type: 'explore', + parent_tool_call_id: 'call_1', + swarm_index: 0, + run_in_background: false, + }, + { + id: 'agent_2', + session_id: 's1', + kind: 'subagent', + description: 'inspect the cancellation path', status: 'running', subagent_phase: 'working', subagent_type: 'explore', parent_tool_call_id: 'call_1', - swarm_index: 0, + swarm_index: 1, run_in_background: false, }, ]); + }); - main.bus.emit(agentEvent('subagent.completed', { subagentId: 'agent_1', resultSummary: 'done' })); + it('getSnapshotState drops the live subagent roster when the main turn ends', async () => { + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + sessions.set('s1', lc); + await bc.subscribe('s1', collectingTarget().target); + + main.bus.emit( + agentEvent('subagent.spawned', { + subagentId: 'agent_1', + description: 'inspect the auth flow', + runInBackground: false, + }), + ); main.bus.emit(agentEvent('turn.started', { turnId: 1 })); - main.bus.emit(agentEvent('turn.ended', { turnId: 1 })); + main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); const after = await bc.getSnapshotState('s1'); expect(after.subagents).toEqual([]); }); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index 0f8f815388..e2683ee5d0 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -98,10 +98,21 @@ describe('SubagentRosterTracker', () => { }); }); - it('drops the roster on turn.ended', () => { + it('keeps the roster when a child agent turn ends', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); - t.apply(SID, ev({ type: 'turn.ended', turnId: 1 })); + t.apply(SID, spawned({ subagentId: 'agent_2', swarmIndex: 1 })); + t.apply( + SID, + ev({ type: 'turn.ended', agentId: 'agent_1', turnId: 1, reason: 'completed' }), + ); + expect(t.get(SID).map((entry) => entry.id)).toEqual(['agent_1', 'agent_2']); + }); + + it('drops the roster when the main agent turn ends', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); expect(t.get(SID)).toEqual([]); }); From 2bd6a8b5cbb1cb797ce26e0181f347b876070a4b Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 13 Jul 2026 21:46:50 +0800 Subject: [PATCH 3/4] fix(kap-server): project run_in_background onto REST tasks agent-core-v2 registers foreground Agent runs in the task service too (detached: false, so the LLM can drive them via TaskList/TaskOutput/ TaskStop). The REST projection dropped that distinction and the web client defaults subagent tasks to background, so foreground subagents showed up in the dock as entries that never settle and cannot be stopped. Add the optional run_in_background field to the protocol Task schema and project info.detached onto it; older servers keep omitting the field and the client default stays correct for them. --- .changeset/kap-server-task-foreground-flag.md | 5 +++ packages/kap-server/src/routes/tasks.ts | 5 +++ packages/kap-server/test/tasks.test.ts | 37 +++++++++++++++++++ packages/protocol/src/__tests__/task.test.ts | 5 +++ packages/protocol/src/task.ts | 6 +++ 5 files changed, 58 insertions(+) create mode 100644 .changeset/kap-server-task-foreground-flag.md diff --git a/.changeset/kap-server-task-foreground-flag.md b/.changeset/kap-server-task-foreground-flag.md new file mode 100644 index 0000000000..5d6f1e1008 --- /dev/null +++ b/.changeset/kap-server-task-foreground-flag.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Keep foreground subagents out of the dock's background-task list on the v2 backend, where they appeared as unstoppable entries. diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index 922367a11c..f3a4e4e5ab 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -360,6 +360,11 @@ function toWireTask( // running tasks usually start immediately after creation. created_at: createdIso, started_at: createdIso, + // `detached === false` marks a task a tool call is still waiting on in the + // foreground (v2 registers those too, e.g. foreground Agent runs) — the web + // dock must not list them as background work. Legacy records without the + // flag count as detached. + run_in_background: info.detached !== false, }; if (info.endedAt !== null && info.endedAt !== undefined) { base.completed_at = new Date(info.endedAt).toISOString(); diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index e59f58f18d..14ec330804 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -34,6 +34,7 @@ interface TaskWire { completed_at?: string; output_preview?: string; output_bytes?: number; + run_in_background?: boolean; } interface ListWire { @@ -197,6 +198,42 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { }); }); + it('marks foreground vs background tasks via run_in_background', async () => { + const id = await createSession(); + const tasks = await mainAgentTasks(id); + const backgroundId = tasks.registerTask(fakeTask('agent')); + const foregroundId = tasks.registerTask(fakeTask('agent'), { detached: false }); + await flush(); + + const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); + const byId = new Map(body.data.items.map((t) => [t.id, t])); + expect(byId.get(backgroundId)?.run_in_background).toBe(true); + expect(byId.get(foregroundId)?.run_in_background).toBe(false); + }); + + it('drops settled foreground tasks from the list but keeps settled background ones', async () => { + const id = await createSession(); + const tasks = await mainAgentTasks(id); + const settleNow = (): AgentTask => ({ + ...fakeTask('agent'), + start: (sink) => { + void sink.settle({ status: 'completed' }); + }, + }); + const foregroundId = tasks.registerTask(settleNow(), { detached: false }); + const backgroundId = tasks.registerTask(settleNow()); + await flush(); + + const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); + // Terminal foreground tasks are deliberately hidden (shouldListTask); + // terminal background tasks stay as ghost records. + expect(body.data.items.some((t) => t.id === foregroundId)).toBe(false); + expect(body.data.items.find((t) => t.id === backgroundId)).toMatchObject({ + status: 'completed', + run_in_background: true, + }); + }); + it('filters the list by wire status', async () => { const id = await createSession(); const tasks = await mainAgentTasks(id); diff --git a/packages/protocol/src/__tests__/task.test.ts b/packages/protocol/src/__tests__/task.test.ts index 4bcffbdf01..c506440834 100644 --- a/packages/protocol/src/__tests__/task.test.ts +++ b/packages/protocol/src/__tests__/task.test.ts @@ -69,4 +69,9 @@ describe('taskSchema', () => { const bad = { ...full, created_at: '2026-06-04T10:00:00' }; expect(taskSchema.safeParse(bad).success).toBe(false); }); + + it('accepts the optional run_in_background flag', () => { + expect(taskSchema.parse({ ...full, run_in_background: false }).run_in_background).toBe(false); + expect(taskSchema.parse(full).run_in_background).toBeUndefined(); + }); }); diff --git a/packages/protocol/src/task.ts b/packages/protocol/src/task.ts index bc08eec71f..c18d18f043 100644 --- a/packages/protocol/src/task.ts +++ b/packages/protocol/src/task.ts @@ -25,6 +25,12 @@ export const taskSchema = z.object({ completed_at: isoDateTimeSchema.optional(), output_preview: z.string().optional(), output_bytes: z.number().int().nonnegative().optional(), + /** + * `false` = a tool call is still waiting on this task in the foreground + * (not detached background work). Optional for cross-version tolerance: + * older servers omit it and served only background tasks. + */ + run_in_background: z.boolean().optional(), }); export type Task = z.infer; From b3ff5bd159fabb88d027b295abd0e32b5c9b1398 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 06:03:58 +0800 Subject: [PATCH 4/4] fix(kap-server): keep foreground tasks out of polling --- apps/kimi-web/test/workspace-state.test.ts | 50 ++++++++++++++++++++++ packages/kap-server/src/routes/tasks.ts | 11 ++--- packages/kap-server/test/tasks.test.ts | 9 +++- 3 files changed, 63 insertions(+), 7 deletions(-) diff --git a/apps/kimi-web/test/workspace-state.test.ts b/apps/kimi-web/test/workspace-state.test.ts index 6e5c4c6821..ade7bf320d 100644 --- a/apps/kimi-web/test/workspace-state.test.ts +++ b/apps/kimi-web/test/workspace-state.test.ts @@ -5,6 +5,7 @@ import { DaemonApiError } from '../src/api/errors'; import { createInitialState } from '../src/api/daemon/eventReducer'; import { mergeWorkspaces } from '../src/lib/mergeWorkspaces'; import { loadWorkspaceNameOverrides, saveWorkspaceNameOverrides } from '../src/lib/storage'; +import { useTaskPoller } from '../src/composables/client/useTaskPoller'; import { useWorkspaceState, type UseWorkspaceStateDeps } from '../src/composables/client/useWorkspaceState'; import type { ExtendedState } from '../src/composables/useKimiWebClient'; @@ -26,6 +27,7 @@ const apiMock = vi.hoisted(() => ({ getHealth: vi.fn(), getMeta: vi.fn(), listSessions: vi.fn(), + listTasks: vi.fn(), listWorkspaces: vi.fn(), })); @@ -1270,3 +1272,51 @@ describe('useWorkspaceState — loadAllSessions usage preservation', () => { expect(next[0].usage.contextTokens).toBe(0); }); }); + +describe('useTaskPoller — foreground subagent identity', () => { + beforeEach(() => { + apiMock.listTasks.mockReset(); + }); + + it('keeps the snapshot task when REST refreshes only background tasks', async () => { + const state = createState(); + const foreground: AppTask = { + id: 'agent-1', + sessionId: 'sess_1', + kind: 'subagent', + description: 'Review files', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + subagentType: 'explore', + parentToolCallId: 'call-1', + swarmIndex: 0, + runInBackground: false, + }; + const oldBackground: AppTask = { + id: 'bash-1', + sessionId: 'sess_1', + kind: 'bash', + description: 'Run tests', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + outputPreview: 'old output', + }; + const refreshedBackground: AppTask = { + ...oldBackground, + description: 'Fresh background task', + outputPreview: 'fresh output', + }; + const poller = useTaskPoller(state, computed(() => [])); + state.tasksBySession = { sess_1: [foreground, oldBackground] }; + apiMock.listTasks.mockResolvedValue([refreshedBackground]); + + await poller.loadTasksForSession('sess_1'); + + const tasks = state.tasksBySession.sess_1 ?? []; + expect(tasks.find((task) => task.id === foreground.id)).toEqual(foreground); + expect(tasks.find((task) => task.id === oldBackground.id)).toMatchObject({ + description: 'Fresh background task', + outputPreview: 'fresh output', + }); + }); +}); diff --git a/packages/kap-server/src/routes/tasks.ts b/packages/kap-server/src/routes/tasks.ts index f3a4e4e5ab..ddc0f84aee 100644 --- a/packages/kap-server/src/routes/tasks.ts +++ b/packages/kap-server/src/routes/tasks.ts @@ -122,11 +122,12 @@ export function registerTasksRoutes(app: TasksRouteHost, core: Scope): void { return; } - // `list(false)` = include terminal (ghost) tasks, matching v1 which - // lists everything and filters by wire status in-memory. - const all = (resolved.tasks?.list(false) ?? []).map((info) => - toWireTask(session_id, info), - ); + // `list(false)` includes terminal ghost records. The v1 endpoint lists + // only background tasks, while v2 also tracks foreground work internally, + // so keep foreground ownership on the snapshot / WS path. + const all = (resolved.tasks?.list(false) ?? []) + .filter((info) => info.detached !== false) + .map((info) => toWireTask(session_id, info)); const query = req.query as { status?: TaskStatus }; const items = query.status !== undefined ? all.filter((t) => t.status === query.status) : all; diff --git a/packages/kap-server/test/tasks.test.ts b/packages/kap-server/test/tasks.test.ts index 14ec330804..5d21a560aa 100644 --- a/packages/kap-server/test/tasks.test.ts +++ b/packages/kap-server/test/tasks.test.ts @@ -198,7 +198,7 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { }); }); - it('marks foreground vs background tasks via run_in_background', async () => { + it('lists only background tasks while GET preserves the foreground flag', async () => { const id = await createSession(); const tasks = await mainAgentTasks(id); const backgroundId = tasks.registerTask(fakeTask('agent')); @@ -208,7 +208,12 @@ describe('server-v2 /api/v1/sessions/{sid}/tasks', () => { const { body } = await getJson(`/api/v1/sessions/${id}/tasks`); const byId = new Map(body.data.items.map((t) => [t.id, t])); expect(byId.get(backgroundId)?.run_in_background).toBe(true); - expect(byId.get(foregroundId)?.run_in_background).toBe(false); + expect(byId.has(foregroundId)).toBe(false); + + const foreground = await getJson( + `/api/v1/sessions/${id}/tasks/${foregroundId}`, + ); + expect(foreground.body.data.run_in_background).toBe(false); }); it('drops settled foreground tasks from the list but keeps settled background ones', async () => {