From 02171122fb52b7a6f796609a1365f597ad7f32c5 Mon Sep 17 00:00:00 2001 From: qer Date: Mon, 13 Jul 2026 21:21:36 +0800 Subject: [PATCH 1/9] 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/9] 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 bef5c98c790e9e589e78b66c238a8abb3bbe0b8f Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 06:47:10 +0800 Subject: [PATCH 3/9] fix(kap-server): track detached subagents in roster --- .../transport/ws/v1/subagentRosterTracker.ts | 9 +++++ .../test/sessionEventBroadcaster.test.ts | 39 +++++++++++++++++++ .../test/subagentRosterTracker.test.ts | 18 +++++++++ 3 files changed, 66 insertions(+) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index 5098871ddc..44617c8633 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -62,6 +62,15 @@ export class SubagentRosterTracker { entry.suspended_reason = event.reason; return; } + case 'task.started': { + if (event.info.kind !== 'agent') return; + const agentId = event.info.agentId; + if (agentId === undefined) return; + const entry = this.bySession.get(sessionId)?.get(agentId); + if (!entry) return; + entry.run_in_background = true; + return; + } case 'subagent.completed': { const entry = this.bySession.get(sessionId)?.get(event.subagentId); if (!entry) return; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 3e1356413a..3cff8e9849 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -353,6 +353,45 @@ describe('SessionEventBroadcaster', () => { ]); }); + it('getSnapshotState reflects a foreground subagent detached by its Agent task', 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', + runInBackground: false, + }), + ); + main.bus.emit( + agentEvent('task.started', { + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + description: 'explore the auth flow', + status: 'running', + detached: true, + startedAt: Date.now(), + endedAt: null, + }, + }), + ); + + const snapshot = await bc.getSnapshotState('s1'); + expect(snapshot.subagents).toMatchObject([ + { + id: 'agent_1', + run_in_background: true, + }, + ]); + }); + it('getSnapshotState drops the live subagent roster when the main turn ends', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index e2683ee5d0..cbda751cf3 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -75,6 +75,24 @@ describe('SubagentRosterTracker', () => { expect(resumed.suspended_reason).toBeUndefined(); }); + it('marks a foreground subagent as background when its Agent task detaches', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply( + SID, + ev({ + type: 'task.started', + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + detached: true, + }, + }), + ); + expect(t.get(SID)[0]?.run_in_background).toBe(true); + }); + it('records completion with the result summary as output preview', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); From 4e9e4764ce1e4aa39b1879ec668a6a44c63fdc6e Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 07:02:12 +0800 Subject: [PATCH 4/9] fix(kap-server): close detached subagent roster entries --- .../transport/ws/v1/subagentRosterTracker.ts | 17 ++++++++++ .../test/sessionEventBroadcaster.test.ts | 33 +++++++++++++++++-- .../test/subagentRosterTracker.test.ts | 29 ++++++++++++++++ 3 files changed, 76 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 44617c8633..5228256fe7 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -71,6 +71,23 @@ export class SubagentRosterTracker { entry.run_in_background = true; return; } + case 'task.terminated': { + if (event.info.kind !== 'agent' || event.info.status === 'running') return; + const agentId = event.info.agentId; + if (agentId === undefined) return; + const entry = this.bySession.get(sessionId)?.get(agentId); + if (!entry) return; + entry.status = + event.info.status === 'completed' + ? 'completed' + : event.info.status === 'killed' + ? 'cancelled' + : 'failed'; + entry.subagent_phase = event.info.status === 'completed' ? 'completed' : 'failed'; + entry.suspended_reason = undefined; + entry.completed_at = new Date(event.info.endedAt ?? Date.now()).toISOString(); + return; + } case 'subagent.completed': { const entry = this.bySession.get(sessionId)?.get(event.subagentId); if (!entry) return; diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 3cff8e9849..58e126ae33 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -353,7 +353,7 @@ describe('SessionEventBroadcaster', () => { ]); }); - it('getSnapshotState reflects a foreground subagent detached by its Agent task', async () => { + it('getSnapshotState follows a foreground Agent task through detach and stop', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); sessions.set('s1', lc); @@ -383,10 +383,37 @@ describe('SessionEventBroadcaster', () => { }), ); - const snapshot = await bc.getSnapshotState('s1'); - expect(snapshot.subagents).toMatchObject([ + const detached = await bc.getSnapshotState('s1'); + expect(detached.subagents).toMatchObject([ { id: 'agent_1', + status: 'running', + run_in_background: true, + }, + ]); + + main.bus.emit( + agentEvent('task.terminated', { + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + description: 'explore the auth flow', + status: 'killed', + detached: true, + startedAt: Date.now(), + endedAt: Date.now(), + stopReason: 'Stopped by the user', + }, + }), + ); + + const stopped = await bc.getSnapshotState('s1'); + expect(stopped.subagents).toMatchObject([ + { + id: 'agent_1', + status: 'cancelled', + subagent_phase: 'failed', run_in_background: true, }, ]); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index cbda751cf3..e7b030af78 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -93,6 +93,35 @@ describe('SubagentRosterTracker', () => { expect(t.get(SID)[0]?.run_in_background).toBe(true); }); + it.each([ + ['completed', 'completed', 'completed'], + ['failed', 'failed', 'failed'], + ['timed_out', 'failed', 'failed'], + ['killed', 'cancelled', 'failed'], + ['lost', 'failed', 'failed'], + ] as const)('maps Agent task terminal status %s into the snapshot', (taskStatus, status, phase) => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply( + SID, + ev({ + type: 'task.terminated', + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + status: taskStatus, + endedAt: 1_700_000_000_000, + }, + }), + ); + expect(t.get(SID)[0]).toMatchObject({ + status, + subagent_phase: phase, + completed_at: '2023-11-14T22:13:20.000Z', + }); + }); + it('records completion with the result summary as output preview', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); From b4f76374dc4947d4c4150e63403c569e8caca493 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 07:15:40 +0800 Subject: [PATCH 5/9] fix(kap-server): keep roster terminal states monotonic --- .../transport/ws/v1/subagentRosterTracker.ts | 15 ++-- .../test/sessionEventBroadcaster.test.ts | 70 +++++++++++++++ .../test/subagentRosterTracker.test.ts | 85 ++++++++++++++++--- 3 files changed, 154 insertions(+), 16 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index 5228256fe7..e9d26c251a 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -46,7 +46,7 @@ export class SubagentRosterTracker { return; } case 'subagent.started': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); + const entry = this.runningEntry(sessionId, event.subagentId); if (!entry) return; entry.subagent_phase = 'working'; entry.suspended_reason = undefined; @@ -56,7 +56,7 @@ export class SubagentRosterTracker { return; } case 'subagent.suspended': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); + const entry = this.runningEntry(sessionId, event.subagentId); if (!entry) return; entry.subagent_phase = 'suspended'; entry.suspended_reason = event.reason; @@ -66,7 +66,7 @@ export class SubagentRosterTracker { if (event.info.kind !== 'agent') return; const agentId = event.info.agentId; if (agentId === undefined) return; - const entry = this.bySession.get(sessionId)?.get(agentId); + const entry = this.runningEntry(sessionId, agentId); if (!entry) return; entry.run_in_background = true; return; @@ -89,7 +89,7 @@ export class SubagentRosterTracker { return; } case 'subagent.completed': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); + const entry = this.runningEntry(sessionId, event.subagentId); if (!entry) return; entry.subagent_phase = 'completed'; entry.status = 'completed'; @@ -98,7 +98,7 @@ export class SubagentRosterTracker { return; } case 'subagent.failed': { - const entry = this.bySession.get(sessionId)?.get(event.subagentId); + const entry = this.runningEntry(sessionId, event.subagentId); if (!entry) return; entry.subagent_phase = 'failed'; entry.status = 'failed'; @@ -119,6 +119,11 @@ export class SubagentRosterTracker { } } + private runningEntry(sessionId: string, agentId: string): SnapshotSubagent | undefined { + const entry = this.bySession.get(sessionId)?.get(agentId); + return entry?.status === 'running' ? entry : undefined; + } + /** Fresh copies — callers must not mutate the tracked entries. */ get(sessionId: string): SnapshotSubagent[] { const roster = this.bySession.get(sessionId); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 58e126ae33..3051bf3953 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -417,8 +417,78 @@ describe('SessionEventBroadcaster', () => { run_in_background: true, }, ]); + + main.bus.emit( + agentEvent('subagent.completed', { + subagentId: 'agent_1', + resultSummary: 'late completion', + }), + ); + main.bus.emit(agentEvent('subagent.started', { subagentId: 'agent_1' })); + + const afterLateEvents = await bc.getSnapshotState('s1'); + expect(afterLateEvents.subagents).toEqual(stopped.subagents); }); + it.each([ + ['subagent.completed', 'killed', 'cancelled', 'failed'], + ['subagent.failed', 'completed', 'completed', 'completed'], + ] as const)( + 'getSnapshotState lets task.terminated override an earlier %s', + async (subagentEvent, taskStatus, status, phase) => { + 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 terminal ordering', + runInBackground: true, + }), + ); + if (subagentEvent === 'subagent.completed') { + main.bus.emit( + agentEvent(subagentEvent, { + subagentId: 'agent_1', + resultSummary: 'early completion', + }), + ); + } else { + main.bus.emit( + agentEvent(subagentEvent, { + subagentId: 'agent_1', + error: 'early failure', + }), + ); + } + main.bus.emit( + agentEvent('task.terminated', { + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + description: 'inspect terminal ordering', + status: taskStatus, + detached: true, + startedAt: Date.now(), + endedAt: Date.now(), + }, + }), + ); + + const snapshot = await bc.getSnapshotState('s1'); + expect(snapshot.subagents).toMatchObject([ + { + id: 'agent_1', + status, + subagent_phase: phase, + }, + ]); + }, + ); + it('getSnapshotState drops the live subagent roster when the main turn ends', async () => { const lc = new FakeLifecycle(); const main = lc.addAgent('main'); diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index e7b030af78..f280e4e98b 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -26,6 +26,19 @@ function spawned(overrides: Record = {}): Event { }); } +function taskTerminated(status: string, agentId = 'agent_1'): Event { + return ev({ + type: 'task.terminated', + info: { + taskId: `task-${agentId}`, + kind: 'agent', + agentId, + status, + endedAt: 1_700_000_000_000, + }, + }); +} + describe('SubagentRosterTracker', () => { it('records the full swarm identity on spawn', () => { const t = new SubagentRosterTracker(); @@ -102,23 +115,73 @@ describe('SubagentRosterTracker', () => { ] as const)('maps Agent task terminal status %s into the snapshot', (taskStatus, status, phase) => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); + t.apply(SID, taskTerminated(taskStatus)); + expect(t.get(SID)[0]).toMatchObject({ + status, + subagent_phase: phase, + completed_at: '2023-11-14T22:13:20.000Z', + }); + }); + + it('keeps a killed task terminal when subagent.completed arrives late', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, taskTerminated('killed')); + const terminal = t.get(SID)[0]; + + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'agent_1', resultSummary: 'late' })); + + expect(t.get(SID)[0]).toEqual(terminal); + }); + + it.each(['timed_out', 'lost'])('keeps %s terminal across late lifecycle events', (status) => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, taskTerminated(status)); + const terminal = t.get(SID)[0]; + + t.apply(SID, ev({ type: 'subagent.failed', subagentId: 'agent_1', error: 'late' })); + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_1' })); + t.apply(SID, ev({ type: 'subagent.suspended', subagentId: 'agent_1', reason: 'late' })); t.apply( SID, ev({ - type: 'task.terminated', - info: { - taskId: 'agent-task-1', - kind: 'agent', - agentId: 'agent_1', - status: taskStatus, - endedAt: 1_700_000_000_000, - }, + type: 'task.started', + info: { kind: 'agent', agentId: 'agent_1', taskId: 'late-task' }, }), ); + + expect(t.get(SID)[0]).toEqual(terminal); + }); + + it('keeps running siblings updateable and ignores unknown ids after a terminal event', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, spawned({ subagentId: 'agent_2', swarmIndex: 1 })); + t.apply(SID, taskTerminated('killed')); + + t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_2' })); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'late' })); + + expect(t.get(SID)).toMatchObject([ + { id: 'agent_1', status: 'cancelled', subagent_phase: 'failed' }, + { id: 'agent_2', status: 'running', subagent_phase: 'working' }, + ]); + }); + + it('lets a new spawn reset a terminal entry with the same agent id', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply(SID, taskTerminated('killed')); + + t.apply(SID, spawned({ description: 'new run', runInBackground: false })); + expect(t.get(SID)[0]).toMatchObject({ - status, - subagent_phase: phase, - completed_at: '2023-11-14T22:13:20.000Z', + id: 'agent_1', + description: 'new run', + status: 'running', + subagent_phase: 'queued', + run_in_background: false, }); }); From 0552bbc38a0ea55cc6759d96b7f129aee7c47e41 Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 07:24:00 +0800 Subject: [PATCH 6/9] fix(web): honor authoritative snapshot rosters --- apps/kimi-web/src/api/daemon/client.ts | 4 +- apps/kimi-web/src/api/types.ts | 4 +- apps/kimi-web/src/lib/taskMerge.ts | 18 ++++--- apps/kimi-web/test/daemon-client.test.ts | 63 +++++++++++++++++++++++- apps/kimi-web/test/lib-logic.test.ts | 33 +++++++++++-- 5 files changed, 106 insertions(+), 16 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 4a9e5e10e0..70f8a0c2cc 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -509,8 +509,8 @@ export class DaemonKimiWebApi implements KimiWebApi { }, pendingApprovals: data.pending_approvals.map(toAppApprovalRequest), pendingQuestions: data.pending_questions.map(toAppQuestionRequest), - // Older servers omit the roster entirely; treat as an empty roster. - subagents: (data.subagents ?? []).map(toAppTask), + // Preserve omission for older servers; a present empty roster is authoritative. + subagents: data.subagents?.map(toAppTask), }; } diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index def845bca2..c6b7b43228 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -498,8 +498,8 @@ export interface AppSessionSnapshot { messages: AppMessage[]; hasMoreMessages: boolean; inFlightTurn: AppInFlightTurn | null; - /** Live subagent roster at the watermark — rebuilds swarm cards on refresh. */ - subagents: AppTask[]; + /** Live subagent roster at the watermark; absent when an older server does not provide it. */ + subagents?: AppTask[]; pendingApprovals: AppApprovalRequest[]; pendingQuestions: AppQuestionRequest[]; } diff --git a/apps/kimi-web/src/lib/taskMerge.ts b/apps/kimi-web/src/lib/taskMerge.ts index 17a7660767..593fb7fdf1 100644 --- a/apps/kimi-web/src/lib/taskMerge.ts +++ b/apps/kimi-web/src/lib/taskMerge.ts @@ -22,13 +22,16 @@ export function keepLiveSubagents(restBased: AppTask[], existing: AppTask[]): Ap } /** - * Seed the task store from the snapshot's subagent roster. The roster is - * authoritative for identity/status/phase; keep reducer-owned accumulated - * output (outputLines/text) from any already-live task, and keep tasks the - * roster does not know about (background bash tasks from REST). + * Seed the task store from the snapshot's subagent roster. When present, the + * roster is authoritative for all subagents, while reducer-owned accumulated + * output (outputLines/text) and non-subagent tasks survive the seed. Older + * servers omit the roster, so `undefined` keeps the existing store unchanged. */ -export function mergeSnapshotSubagents(roster: AppTask[], existing: AppTask[]): AppTask[] { - if (roster.length === 0) return existing; +export function mergeSnapshotSubagents( + roster: AppTask[] | undefined, + existing: AppTask[], +): AppTask[] { + if (roster === undefined) return existing; const existingById = new Map(existing.map((t) => [t.id, t] as const)); const rosterIds = new Set(roster.map((t) => t.id)); const merged = roster.map((task) => { @@ -36,6 +39,7 @@ export function mergeSnapshotSubagents(roster: AppTask[], existing: AppTask[]): if (!live) return task; return { ...task, outputLines: live.outputLines, text: live.text }; }); - const kept = existing.filter((t) => !rosterIds.has(t.id)); + const kept = existing.filter((t) => t.kind !== 'subagent' && !rosterIds.has(t.id)); + if (merged.length === 0 && kept.length === existing.length) return existing; return kept.length === 0 ? merged : [...merged, ...kept]; } diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts index d24461289d..28ec0bfffc 100644 --- a/apps/kimi-web/test/daemon-client.test.ts +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -1,6 +1,5 @@ // apps/kimi-web/test/daemon-client.test.ts -// DaemonKimiWebApi.getSessionGoal — wire → app mapping of GET /sessions/{id}/goal: -// a present snapshot, explicit null (no active goal), and the request URL. +// DaemonKimiWebApi wire → app mapping for session goal and snapshot endpoints. import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -34,6 +33,38 @@ const WIRE_GOAL = { }, }; +const WIRE_SESSION_SNAPSHOT = { + as_of_seq: 3, + epoch: 'epoch_1', + session: { + id: 'sess_1', + title: 'Test session', + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:01.000Z', + status: 'idle', + archived: false, + metadata: { cwd: '/workspace' }, + agent_config: { model: 'test-model' }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_creation_tokens: 0, + total_cost_usd: 0, + context_tokens: 0, + context_limit: 0, + turn_count: 0, + }, + permission_rules: [], + message_count: 0, + last_seq: 3, + }, + messages: { items: [], has_more: false }, + in_flight_turn: null, + pending_approvals: [], + pending_questions: [], +}; + function createApi(): DaemonKimiWebApi { return new DaemonKimiWebApi({ serverHttpUrl: 'http://daemon.test', @@ -75,3 +106,31 @@ describe('DaemonKimiWebApi.getSessionGoal', () => { ); }); }); + +describe('DaemonKimiWebApi.getSessionSnapshot', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('preserves an omitted subagent roster from an older server', async () => { + vi.mocked(fetch).mockResolvedValue(envelope(WIRE_SESSION_SNAPSHOT)); + + const snapshot = await createApi().getSessionSnapshot('sess_1'); + + expect(snapshot.subagents).toBeUndefined(); + }); + + it('preserves an explicitly empty authoritative subagent roster', async () => { + vi.mocked(fetch).mockResolvedValue( + envelope({ ...WIRE_SESSION_SNAPSHOT, subagents: [] }), + ); + + const snapshot = await createApi().getSessionSnapshot('sess_1'); + + expect(snapshot.subagents).toEqual([]); + }); +}); diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 7562f6cc0c..2e29741acb 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -606,7 +606,7 @@ describe('mergeSnapshotSubagents', () => { expect(merged?.text).toBe('partial answer'); }); - it('keeps tasks the roster does not know about', () => { + it('keeps non-subagent tasks alongside the authoritative roster', () => { const background: AppTask = { id: 'bash-1', sessionId: 's1', @@ -620,8 +620,35 @@ describe('mergeSnapshotSubagents', () => { expect(merged.map((t) => t.id)).toEqual(['a1', 'bash-1']); }); - it('returns the existing list untouched when the roster is empty', () => { + it('removes stale subagents when the authoritative roster is empty', () => { + const background: AppTask = { + id: 'bash-1', + sessionId: 's1', + kind: 'bash', + description: 'npm test', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; + const merged = mergeSnapshotSubagents([], [subagent('stale'), background]); + expect(merged).toEqual([background]); + }); + + it('keeps existing tasks when an older snapshot omits the roster', () => { const existing = [subagent('a1')]; - expect(mergeSnapshotSubagents([], existing)).toBe(existing); + expect(mergeSnapshotSubagents(undefined, existing)).toBe(existing); + }); + + it('replaces subagents missing from a non-empty authoritative roster', () => { + const background: AppTask = { + id: 'bash-1', + sessionId: 's1', + kind: 'bash', + description: 'npm test', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + }; + const roster = [subagent('current')]; + const merged = mergeSnapshotSubagents(roster, [subagent('stale'), background]); + expect(merged).toEqual([roster[0], background]); }); }); From 8fae3d8fc9e025437547841d80d000cecd07830c Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 08:19:34 +0800 Subject: [PATCH 7/9] fix: align detached subagent task identity --- .../src/api/daemon/agentEventProjector.ts | 146 ++++++++++++----- apps/kimi-web/src/api/daemon/eventReducer.ts | 65 +++++++- apps/kimi-web/src/api/daemon/mappers.ts | 1 + apps/kimi-web/src/api/daemon/wire.ts | 1 + apps/kimi-web/src/api/types.ts | 15 +- apps/kimi-web/src/lib/taskMerge.ts | 54 +++++- .../test/agent-event-projector.test.ts | 137 ++++++++++++++++ apps/kimi-web/test/daemon-client.test.ts | 28 ++++ apps/kimi-web/test/event-reducer.test.ts | 154 ++++++++++++++++++ apps/kimi-web/test/lib-logic.test.ts | 72 +++++++- .../transport/ws/v1/subagentRosterTracker.ts | 8 + .../test/sessionEventBroadcaster.test.ts | 23 ++- .../test/subagentRosterTracker.test.ts | 68 +++++++- .../protocol/src/__tests__/snapshot.test.ts | 4 +- packages/protocol/src/rest/snapshot.ts | 2 + 15 files changed, 710 insertions(+), 68 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index bcfefbb5d3..fbfc48c84a 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -203,6 +203,7 @@ function patchSubagent( if (typeof subagentId !== 'string' || subagentId.length === 0) return null; const prev = state.subagentMeta.get(subagentId) ?? { id: subagentId, + agentId: subagentId, sessionId, kind: 'subagent', description: 'Sub Agent', @@ -210,11 +211,30 @@ function patchSubagent( createdAt: new Date().toISOString(), subagentPhase: 'queued', } satisfies AppTask; - const next: AppTask = { ...prev, ...patch, id: subagentId, sessionId, kind: 'subagent' }; + const next: AppTask = { + ...prev, + ...patch, + id: patch.id ?? prev.id, + agentId: subagentId, + sessionId, + kind: 'subagent', + }; state.subagentMeta.set(subagentId, next); return next; } +function patchRunningSubagent( + state: SessionState, + sessionId: string, + subagentId: unknown, + patch: Partial, +): AppTask | null { + if (typeof subagentId !== 'string' || subagentId.length === 0) return null; + const current = state.subagentMeta.get(subagentId); + if (current !== undefined && current.status !== 'running') return null; + return patchSubagent(state, sessionId, subagentId, patch); +} + export function subagentProgressText(rawType: string, payload: Record): string | null { // "Started a step" fires on every step and adds no information — the phase // badge already shows the subagent is working, so skip it to cut the noise. @@ -289,36 +309,44 @@ function projectSubagentProgress( // taskProgress to existing tasks — without this, the deltas are dropped and // the live detail stays blank until a non-text frame recreates the task. const previous = state.subagentMeta.get(subagentId); - const task = patchSubagent(state, sessionId, subagentId, { + const task = patchRunningSubagent(state, sessionId, subagentId, { status: 'running', subagentPhase: 'working', startedAt: previous?.startedAt ?? new Date().toISOString(), }); - const out: AppEvent[] = []; - if (task) out.push({ type: 'taskCreated', sessionId, task }); - out.push({ - type: 'taskProgress', - sessionId, - taskId: subagentId, - outputChunk: delta, - stream: 'stdout', - kind: 'text', - }); - return out; + if (!task) return []; + return [ + { type: 'taskCreated', sessionId, task }, + { + type: 'taskProgress', + sessionId, + taskId: task.id, + outputChunk: delta, + stream: 'stdout', + kind: 'text', + }, + ]; } const text = subagentProgressText(rawType, payload); if (text === null || text.length === 0) return []; const previous = state.subagentMeta.get(subagentId); - const task = patchSubagent(state, sessionId, subagentId, { + const task = patchRunningSubagent(state, sessionId, subagentId, { status: 'running', subagentPhase: 'working', startedAt: previous?.startedAt ?? new Date().toISOString(), }); - const out: AppEvent[] = []; - if (task) out.push({ type: 'taskCreated', sessionId, task }); - out.push({ type: 'taskProgress', sessionId, taskId: subagentId, outputChunk: text, stream: 'stdout' }); - return out; + if (!task) return []; + return [ + { type: 'taskCreated', sessionId, task }, + { + type: 'taskProgress', + sessionId, + taskId: task.id, + outputChunk: text, + stream: 'stdout', + }, + ]; } // --------------------------------------------------------------------------- @@ -1004,6 +1032,7 @@ export function createAgentProjector(): AgentProjector { const taskId = typeof p?.subagentId === 'string' && p.subagentId.length > 0 ? p.subagentId : ulid('task_'); const task: AppTask = { id: taskId, + agentId: taskId, sessionId, kind: 'subagent', description: typeof p?.description === 'string' ? p.description : p?.subagentName ?? 'Sub Agent', @@ -1025,7 +1054,7 @@ export function createAgentProjector(): AgentProjector { } case 'subagent.started': { - const task = patchSubagent(s, sessionId, p?.subagentId, { + const task = patchRunningSubagent(s, sessionId, p?.subagentId, { subagentPhase: 'working', status: 'running', startedAt: new Date().toISOString(), @@ -1035,7 +1064,7 @@ export function createAgentProjector(): AgentProjector { } case 'subagent.suspended': { - const task = patchSubagent(s, sessionId, p?.subagentId, { + const task = patchRunningSubagent(s, sessionId, p?.subagentId, { subagentPhase: 'suspended', status: 'running', suspendedReason: typeof p?.reason === 'string' ? p.reason : undefined, @@ -1046,39 +1075,25 @@ export function createAgentProjector(): AgentProjector { case 'subagent.completed': { const outputPreview = typeof p?.resultSummary === 'string' ? p.resultSummary : undefined; - const task = patchSubagent(s, sessionId, p?.subagentId, { + const task = patchRunningSubagent(s, sessionId, p?.subagentId, { subagentPhase: 'completed', status: 'completed', completedAt: new Date().toISOString(), outputPreview, }); if (task) out.push({ type: 'taskCreated', sessionId, task }); - out.push({ - type: 'taskCompleted', - sessionId, - taskId: p?.subagentId ?? '', - status: 'completed', - outputPreview, - }); break; } case 'subagent.failed': { const outputPreview = typeof p?.error === 'string' ? p.error : undefined; - const task = patchSubagent(s, sessionId, p?.subagentId, { + const task = patchRunningSubagent(s, sessionId, p?.subagentId, { subagentPhase: 'failed', status: 'failed', completedAt: new Date().toISOString(), outputPreview, }); if (task) out.push({ type: 'taskCreated', sessionId, task }); - out.push({ - type: 'taskCompleted', - sessionId, - taskId: p?.subagentId ?? '', - status: 'failed', - outputPreview, - }); break; } @@ -1101,7 +1116,7 @@ export function createAgentProjector(): AgentProjector { } // ----------------------------------------------------------------------- - // Tasks (e.g. a detached Bash command). Real daemon shape: + // Tasks (e.g. a detached Bash command or Agent run). Real daemon shape: // payload.info = { taskId, description, status, startedAt(ms), endedAt, // kind:'process', command, pid, exitCode }. case 'task.started': { @@ -1114,6 +1129,18 @@ export function createAgentProjector(): AgentProjector { : typeof info.taskId === 'number' ? String(info.taskId) : ulid('task_'); + const agentId = typeof info.agentId === 'string' ? info.agentId : undefined; + if (info.kind === 'agent' && agentId !== undefined) { + const current = s.subagentMeta.get(agentId); + const task = patchSubagent(s, sessionId, agentId, { + id: taskId, + status: current?.status ?? 'running', + startedAt, + runInBackground: true, + }); + if (task) out.push({ type: 'taskCreated', sessionId, task }); + break; + } const description = typeof info.description === 'string' ? info.description @@ -1140,18 +1167,51 @@ export function createAgentProjector(): AgentProjector { } case 'task.terminated': { const info = (p?.info ?? {}) as Record; + const agentId = typeof info.agentId === 'string' ? info.agentId : undefined; + const taskId = + typeof info.taskId === 'string' + ? info.taskId + : typeof info.taskId === 'number' + ? String(info.taskId) + : ''; + if (info.kind === 'agent' && agentId !== undefined) { + const status = + info.status === 'completed' + ? 'completed' + : info.status === 'killed' + ? 'cancelled' + : 'failed'; + const task = patchSubagent(s, sessionId, agentId, { + id: taskId.length > 0 ? taskId : agentId, + status, + subagentPhase: status === 'completed' ? 'completed' : 'failed', + completedAt: + typeof info.endedAt === 'number' + ? new Date(info.endedAt).toISOString() + : new Date().toISOString(), + runInBackground: true, + }); + if (task) { + out.push({ + type: 'taskCompleted', + sessionId, + taskId: task.id, + agentId, + status, + subagentPhase: task.subagentPhase, + completedAt: task.completedAt, + runInBackground: true, + }); + } + break; + } const failed = info.status === 'failed' || (typeof info.exitCode === 'number' && info.exitCode !== 0); out.push({ type: 'taskCompleted', sessionId, - taskId: - typeof info.taskId === 'string' - ? info.taskId - : typeof info.taskId === 'number' - ? String(info.taskId) - : '', + taskId, status: failed ? 'failed' : 'completed', // Do NOT set outputPreview here. The command is already kept on the // task as `command`; setting outputPreview to `$ ` would diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 667b70f7d8..86a3cd6b97 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -556,20 +556,49 @@ export function reduceAppEvent( case 'taskCreated': { const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; - const idx = list.findIndex((t) => t.id === event.task.id); + const exactIndex = list.findIndex((t) => t.id === event.task.id); + const aliasIndex = + event.task.agentId === undefined + ? -1 + : list.findIndex( + (t) => + t.agentId === event.task.agentId || + (t.kind === 'subagent' && t.id === event.task.agentId), + ); + const idx = exactIndex !== -1 ? exactIndex : aliasIndex; if (idx === -1) { next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; - const previous = list[idx]!; + const exact = exactIndex === -1 ? undefined : list[exactIndex]; + const previous = aliasIndex === -1 ? list[idx]! : list[aliasIndex]!; + const keepDetachedTaskId = + previous.id !== event.task.id && + event.task.agentId !== undefined && + previous.agentId === event.task.agentId && + event.task.id === event.task.agentId && + event.task.subagentPhase !== 'queued'; + const keepTerminalState = + previous.kind === 'subagent' && + previous.status !== 'running' && + event.task.subagentPhase !== 'queued' && + (event.task.status === 'running' || event.task.id === event.task.agentId); // The projected task does not carry reducer-owned accumulated progress; // preserve it across the replacement so subagent output keeps growing. // A resync also rebuilds skeleton tasks without their identity metadata, // so keep the previous value when the projected task omits it. patched[idx] = { ...event.task, - outputLines: previous.outputLines, - text: previous.text, + id: keepDetachedTaskId ? previous.id : event.task.id, + agentId: event.task.agentId ?? previous.agentId, + status: keepTerminalState ? previous.status : event.task.status, + completedAt: keepTerminalState ? previous.completedAt : event.task.completedAt, + outputPreview: keepTerminalState + ? previous.outputPreview + : (event.task.outputPreview ?? exact?.outputPreview ?? previous.outputPreview), + outputBytes: event.task.outputBytes ?? exact?.outputBytes ?? previous.outputBytes, + outputLines: previous.outputLines ?? exact?.outputLines, + text: previous.text ?? exact?.text, // A post-refresh lifecycle event re-projects the task with skeleton // metadata; don't let its placeholder clobber the roster-seeded // description. @@ -581,9 +610,22 @@ export function reduceAppEvent( swarmIndex: event.task.swarmIndex ?? previous.swarmIndex, parentToolCallId: event.task.parentToolCallId ?? previous.parentToolCallId, subagentType: event.task.subagentType ?? previous.subagentType, + subagentPhase: keepTerminalState + ? previous.subagentPhase + : (event.task.subagentPhase ?? previous.subagentPhase), + suspendedReason: keepTerminalState + ? previous.suspendedReason + : event.task.suspendedReason, runInBackground: event.task.runInBackground ?? previous.runInBackground, }; - next.tasksBySession[sid] = patched; + const duplicateIndex = + exactIndex !== -1 && aliasIndex !== -1 && exactIndex !== aliasIndex + ? aliasIndex + : -1; + next.tasksBySession[sid] = + duplicateIndex === -1 + ? patched + : patched.filter((_task, index) => index !== duplicateIndex); } break; } @@ -593,7 +635,7 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; next.tasksBySession[sid] = list.map((t) => { - if (t.id !== event.taskId) return t; + if (t.id !== event.taskId && t.agentId !== event.taskId) return t; // Subagent streamed output (assistant.delta) concatenates into a single // growing text block rather than fragmenting each delta into its own // line — the detail panel renders it like a thinking block. @@ -619,12 +661,17 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; next.tasksBySession[sid] = list.map((t) => { - if (t.id !== event.taskId) return t; + if (t.id !== event.taskId && t.agentId !== event.taskId) return t; return { ...t, + id: event.agentId === undefined ? t.id : event.taskId, + agentId: event.agentId ?? t.agentId, status: event.status, - outputPreview: event.outputPreview, - outputBytes: event.outputBytes, + subagentPhase: event.subagentPhase ?? t.subagentPhase, + completedAt: event.completedAt ?? t.completedAt, + runInBackground: event.runInBackground ?? t.runInBackground, + outputPreview: event.outputPreview ?? t.outputPreview, + outputBytes: event.outputBytes ?? t.outputBytes, }; }); break; diff --git a/apps/kimi-web/src/api/daemon/mappers.ts b/apps/kimi-web/src/api/daemon/mappers.ts index 648734ae3d..a3a18291ab 100644 --- a/apps/kimi-web/src/api/daemon/mappers.ts +++ b/apps/kimi-web/src/api/daemon/mappers.ts @@ -385,6 +385,7 @@ export function toWireQuestionResponse(input: QuestionResponse): WireQuestionRes export function toAppTask(wire: WireTask): AppTask { return { id: wire.id, + agentId: wire.agent_id, sessionId: wire.session_id, kind: wire.kind, description: wire.description, diff --git a/apps/kimi-web/src/api/daemon/wire.ts b/apps/kimi-web/src/api/daemon/wire.ts index d19cd45e4e..a662a5e216 100644 --- a/apps/kimi-web/src/api/daemon/wire.ts +++ b/apps/kimi-web/src/api/daemon/wire.ts @@ -319,6 +319,7 @@ export type WireTaskStatus = 'running' | 'completed' | 'failed' | 'cancelled'; export interface WireTask { id: string; + agent_id?: string; session_id: string; kind: 'subagent' | 'bash' | 'tool'; description: string; diff --git a/apps/kimi-web/src/api/types.ts b/apps/kimi-web/src/api/types.ts index c6b7b43228..6b3ea0d966 100644 --- a/apps/kimi-web/src/api/types.ts +++ b/apps/kimi-web/src/api/types.ts @@ -307,6 +307,8 @@ export type AppSubagentPhase = 'queued' | 'working' | 'suspended' | 'completed' export interface AppTask { id: string; + /** Stable child-agent identity when `id` is a detached background task ID. */ + agentId?: string; sessionId: string; kind: 'subagent' | 'bash' | 'tool'; description: string; @@ -446,7 +448,18 @@ export type AppEvent = */ kind?: 'line' | 'text'; } - | { type: 'taskCompleted'; sessionId: string; taskId: string; status: AppTaskStatus; outputPreview?: string; outputBytes?: number } + | { + type: 'taskCompleted'; + sessionId: string; + taskId: string; + agentId?: string; + status: AppTaskStatus; + subagentPhase?: AppSubagentPhase; + completedAt?: string; + runInBackground?: boolean; + outputPreview?: string; + outputBytes?: number; + } | { type: 'goalUpdated'; sessionId: string; goal: AppGoal | null } | { type: 'configChanged'; changedFields: string[]; config: AppConfig } | { diff --git a/apps/kimi-web/src/lib/taskMerge.ts b/apps/kimi-web/src/lib/taskMerge.ts index 593fb7fdf1..9ce253b210 100644 --- a/apps/kimi-web/src/lib/taskMerge.ts +++ b/apps/kimi-web/src/lib/taskMerge.ts @@ -17,15 +17,39 @@ import type { AppTask } from '../api/types'; */ export function keepLiveSubagents(restBased: AppTask[], existing: AppTask[]): AppTask[] { const restIds = new Set(restBased.map((t) => t.id)); - const liveSubagents = existing.filter((t) => t.kind === 'subagent' && !restIds.has(t.id)); - return liveSubagents.length === 0 ? restBased : [...restBased, ...liveSubagents]; + const existingById = new Map(existing.map((t) => [t.id, t] as const)); + const mergedRest = restBased.map((task) => { + const live = existingById.get(task.id); + if (task.kind !== 'subagent' || live?.kind !== 'subagent') return task; + return { + ...live, + ...task, + agentId: task.agentId ?? live.agentId, + subagentPhase: task.subagentPhase ?? live.subagentPhase, + subagentType: task.subagentType ?? live.subagentType, + parentToolCallId: task.parentToolCallId ?? live.parentToolCallId, + suspendedReason: task.suspendedReason ?? live.suspendedReason, + swarmIndex: task.swarmIndex ?? live.swarmIndex, + runInBackground: task.runInBackground ?? live.runInBackground, + outputLines: live.outputLines, + text: live.text, + }; + }); + const liveSubagents = existing.filter( + (t) => + t.kind === 'subagent' && + t.runInBackground !== true && + !restIds.has(t.id), + ); + return liveSubagents.length === 0 ? mergedRest : [...mergedRest, ...liveSubagents]; } /** * Seed the task store from the snapshot's subagent roster. When present, the - * roster is authoritative for all subagents, while reducer-owned accumulated - * output (outputLines/text) and non-subagent tasks survive the seed. Older - * servers omit the roster, so `undefined` keeps the existing store unchanged. + * roster is authoritative for roster-owned subagents, while reducer-owned + * accumulated output (outputLines/text), non-subagent tasks, and detached + * REST-backed subagents survive the seed. Older servers omit the roster, so + * `undefined` keeps the existing store unchanged. */ export function mergeSnapshotSubagents( roster: AppTask[] | undefined, @@ -33,13 +57,29 @@ export function mergeSnapshotSubagents( ): AppTask[] { if (roster === undefined) return existing; const existingById = new Map(existing.map((t) => [t.id, t] as const)); + const existingByAgentId = new Map( + existing + .filter((t) => t.kind === 'subagent') + .map((t) => [t.agentId ?? t.id, t] as const), + ); const rosterIds = new Set(roster.map((t) => t.id)); + const rosterAgentIds = new Set(); const merged = roster.map((task) => { - const live = existingById.get(task.id); + const byId = existingById.get(task.id); + const byAgentId = task.agentId === undefined ? undefined : existingByAgentId.get(task.agentId); + const aliasMatch = + byId === undefined && byAgentId?.status === 'running' ? byAgentId : undefined; + const live = byId ?? aliasMatch; + if (aliasMatch !== undefined && task.agentId !== undefined) rosterAgentIds.add(task.agentId); if (!live) return task; return { ...task, outputLines: live.outputLines, text: live.text }; }); - const kept = existing.filter((t) => t.kind !== 'subagent' && !rosterIds.has(t.id)); + const kept = existing.filter( + (t) => + (t.kind !== 'subagent' || t.runInBackground === true) && + !rosterIds.has(t.id) && + !rosterAgentIds.has(t.agentId ?? t.id), + ); if (merged.length === 0 && kept.length === existing.length) return existing; return kept.length === 0 ? merged : [...merged, ...kept]; } diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index 36bf5ec581..58fe37dd52 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -66,6 +66,143 @@ describe('subagent streaming text', () => { }); }); +describe('detached Agent task projection', () => { + it('keeps a detached Agent as one subagent keyed by its task resource id', () => { + const projector = createAgentProjector(); + projector.project( + 'subagent.spawned', + { subagentId: 'agent-1', description: 'Review files', runInBackground: false }, + 's1', + ); + + const events = projector.project( + 'task.started', + { + info: { + taskId: 'agent-task-1', + agentId: 'agent-1', + kind: 'agent', + status: 'running', + startedAt: 1_700_000_000_000, + }, + }, + 's1', + ); + + expect(events).toEqual([ + { + type: 'taskCreated', + sessionId: 's1', + task: expect.objectContaining({ + id: 'agent-task-1', + agentId: 'agent-1', + kind: 'subagent', + runInBackground: true, + }), + }, + ]); + }); + + it('keeps the task resource id on later subagent lifecycle events', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); + + const events = projector.project( + 'subagent.completed', + { subagentId: 'agent-1', resultSummary: 'done' }, + 's1', + ); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ + id: 'agent-task-1', + status: 'completed', + }), + }), + ); + }); + + it.each([ + ['completed', 'completed', 'completed'], + ['failed', 'failed', 'failed'], + ['timed_out', 'failed', 'failed'], + ['killed', 'cancelled', 'failed'], + ['lost', 'failed', 'failed'], + ] as const)('maps Agent task terminal status %s', (taskStatus, status, phase) => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + + const events = projector.project( + 'task.terminated', + { + info: { + taskId: 'agent-task-1', + agentId: 'agent-1', + kind: 'agent', + status: taskStatus, + endedAt: 1_700_000_000_000, + }, + }, + 's1', + ); + + expect(events).toEqual([ + { + type: 'taskCompleted', + sessionId: 's1', + taskId: 'agent-task-1', + agentId: 'agent-1', + status, + subagentPhase: phase, + completedAt: '2023-11-14T22:13:20.000Z', + runInBackground: true, + }, + ]); + }); + + it('does not let late ordinary lifecycle events reopen a terminated Agent', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); + const stopped = projector.project( + 'task.terminated', + { + info: { + taskId: 'agent-task-1', + agentId: 'agent-1', + kind: 'agent', + status: 'killed', + endedAt: 1_700_000_000_000, + }, + }, + 's1', + ); + expect(stopped).toContainEqual( + expect.objectContaining({ type: 'taskCompleted', status: 'cancelled' }), + ); + + expect(projector.project('task.started', { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, 's1')).toContainEqual( + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ id: 'agent-task-1', status: 'cancelled' }), + }), + ); + expect(projector.project('subagent.completed', { subagentId: 'agent-1', resultSummary: 'late' }, 's1')).toEqual([]); + expect(projector.project('subagent.started', { subagentId: 'agent-1' }, 's1')).toEqual([]); + }); +}); + describe('agent error projection', () => { it('drops a subagent error instead of surfacing it as a session warning', () => { const projector = createAgentProjector(); diff --git a/apps/kimi-web/test/daemon-client.test.ts b/apps/kimi-web/test/daemon-client.test.ts index 28ec0bfffc..c119b4b924 100644 --- a/apps/kimi-web/test/daemon-client.test.ts +++ b/apps/kimi-web/test/daemon-client.test.ts @@ -133,4 +133,32 @@ describe('DaemonKimiWebApi.getSessionSnapshot', () => { expect(snapshot.subagents).toEqual([]); }); + + it('maps the task resource id and stable agent id from a detached roster entry', async () => { + vi.mocked(fetch).mockResolvedValue( + envelope({ + ...WIRE_SESSION_SNAPSHOT, + subagents: [ + { + id: 'agent-task-1', + agent_id: 'agent-1', + session_id: 'sess_1', + kind: 'subagent', + description: 'Review files', + status: 'running', + created_at: '2026-01-01T00:00:00.000Z', + run_in_background: true, + }, + ], + }), + ); + + const snapshot = await createApi().getSessionSnapshot('sess_1'); + + expect(snapshot.subagents?.[0]).toMatchObject({ + id: 'agent-task-1', + agentId: 'agent-1', + runInBackground: true, + }); + }); }); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 3aa471c2aa..971d581bc0 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -266,6 +266,160 @@ describe('reduceAppEvent taskProgress', () => { }); }); + it('keeps the detached task id when lifecycle events use the stable agent id', () => { + const detached = { + ...makeSubagentTask('agent-task-1', 's1'), + agentId: 'agent-1', + parentToolCallId: 'call-1', + runInBackground: true, + }; + const state = { + ...createInitialState(), + tasksBySession: { 's1': [detached] }, + }; + + const afterLifecycle = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + subagentPhase: 'working', + }, + }, + { sessionId: 's1', seq: 1 }, + ); + const afterProgress = reduceAppEvent( + afterLifecycle, + { + type: 'taskProgress', + sessionId: 's1', + taskId: 'agent-1', + outputChunk: 'working', + stream: 'stdout', + }, + { sessionId: 's1', seq: 2 }, + ); + + expect(afterProgress.tasksBySession.s1).toHaveLength(1); + expect(afterProgress.tasksBySession.s1?.[0]).toMatchObject({ + id: 'agent-task-1', + agentId: 'agent-1', + parentToolCallId: 'call-1', + outputLines: ['working'], + }); + }); + + it('replaces the foreground agent id with the task resource id on detach', () => { + const foreground = { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + parentToolCallId: 'call-1', + runInBackground: false, + }; + const state = { + ...createInitialState(), + tasksBySession: { 's1': [foreground] }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...foreground, + id: 'agent-task-1', + runInBackground: true, + }, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toHaveLength(1); + expect(next.tasksBySession.s1?.[0]).toMatchObject({ + id: 'agent-task-1', + agentId: 'agent-1', + parentToolCallId: 'call-1', + runInBackground: true, + }); + }); + + it('collapses a REST task row and a foreground alias when the detach event arrives', () => { + const restTask = makeSubagentTask('agent-task-1', 's1'); + const foreground = { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + parentToolCallId: 'call-1', + }; + const state = { + ...createInitialState(), + tasksBySession: { 's1': [restTask, foreground] }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...restTask, + agentId: 'agent-1', + runInBackground: true, + }, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toHaveLength(1); + expect(next.tasksBySession.s1?.[0]).toMatchObject({ + id: 'agent-task-1', + agentId: 'agent-1', + parentToolCallId: 'call-1', + runInBackground: true, + }); + }); + + it('keeps a terminal snapshot state across a late lifecycle alias', () => { + const state = { + ...createInitialState(), + tasksBySession: { + 's1': [ + { + ...makeSubagentTask('agent-task-1', 's1'), + agentId: 'agent-1', + status: 'cancelled' as const, + subagentPhase: 'failed' as const, + runInBackground: true, + }, + ], + }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + status: 'completed', + subagentPhase: 'completed', + }, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1?.[0]).toMatchObject({ + id: 'agent-task-1', + status: 'cancelled', + subagentPhase: 'failed', + }); + }); + it('keeps the roster-seeded description when a re-projected task carries the placeholder', () => { // After a page refresh the snapshot roster seeds the real description; a // later subagent.* lifecycle event re-projects the task with the diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 2e29741acb..372a1437f2 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -9,7 +9,7 @@ import { buildDiffLines } from '../src/lib/diffLines'; import { buildEditDiffLines } from '../src/lib/toolDiff'; import { createCoalescedAsyncRunner } from '../src/lib/snapshotSync'; import { mergeSnapshotMessages } from '../src/lib/snapshotMessages'; -import { mergeSnapshotSubagents } from '../src/lib/taskMerge'; +import { keepLiveSubagents, mergeSnapshotSubagents } from '../src/lib/taskMerge'; import { normalizeToolName, toolSummary } from '../src/lib/toolMeta'; import { collapsePrompt, humanizeCron } from '../src/lib/cronHumanize'; import { @@ -633,6 +633,15 @@ describe('mergeSnapshotSubagents', () => { expect(merged).toEqual([background]); }); + it('keeps a REST-backed detached subagent when the authoritative roster is empty', () => { + const detached = subagent('agent-task-1', { + agentId: 'agent-1', + status: 'completed', + runInBackground: true, + }); + expect(mergeSnapshotSubagents([], [detached])).toEqual([detached]); + }); + it('keeps existing tasks when an older snapshot omits the roster', () => { const existing = [subagent('a1')]; expect(mergeSnapshotSubagents(undefined, existing)).toBe(existing); @@ -651,4 +660,65 @@ describe('mergeSnapshotSubagents', () => { const merged = mergeSnapshotSubagents(roster, [subagent('stale'), background]); expect(merged).toEqual([roster[0], background]); }); + + it('does not merge a previous detached run into a resumed same-agent roster entry', () => { + const oldRun = subagent('agent-task-old', { + agentId: 'agent-1', + status: 'completed', + runInBackground: true, + text: 'old run output', + }); + const resumed = subagent('agent-1', { + agentId: 'agent-1', + subagentPhase: 'queued', + runInBackground: false, + }); + + const merged = mergeSnapshotSubagents([resumed], [oldRun]); + + expect(merged).toEqual([resumed, oldRun]); + }); +}); + +describe('keepLiveSubagents', () => { + it('keeps snapshot identity while REST refreshes a detached subagent', () => { + const snapshot: AppTask = { + id: 'agent-task-1', + agentId: 'agent-1', + sessionId: 's1', + kind: 'subagent', + description: 'Review files', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + subagentPhase: 'working', + subagentType: 'explore', + parentToolCallId: 'call-1', + swarmIndex: 0, + runInBackground: true, + outputLines: ['working'], + }; + const rest: AppTask = { + id: 'agent-task-1', + sessionId: 's1', + kind: 'subagent', + description: 'Review files', + status: 'completed', + createdAt: '2026-01-01T00:00:00.000Z', + completedAt: '2026-01-01T00:01:00.000Z', + runInBackground: true, + }; + + expect(keepLiveSubagents([rest], [snapshot])).toEqual([ + expect.objectContaining({ + id: 'agent-task-1', + agentId: 'agent-1', + status: 'completed', + subagentPhase: 'working', + subagentType: 'explore', + parentToolCallId: 'call-1', + swarmIndex: 0, + outputLines: ['working'], + }), + ]); + }); }); diff --git a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts index e9d26c251a..3b9bfbff53 100644 --- a/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts +++ b/packages/kap-server/src/transport/ws/v1/subagentRosterTracker.ts @@ -32,6 +32,7 @@ export class SubagentRosterTracker { } roster.set(event.subagentId, { id: event.subagentId, + agent_id: event.subagentId, session_id: sessionId, kind: 'subagent', description: event.description ?? event.subagentName ?? 'Sub Agent', @@ -68,6 +69,9 @@ export class SubagentRosterTracker { if (agentId === undefined) return; const entry = this.runningEntry(sessionId, agentId); if (!entry) return; + if (entry.id !== agentId && entry.id !== event.info.taskId) return; + entry.id = event.info.taskId; + entry.agent_id = agentId; entry.run_in_background = true; return; } @@ -77,6 +81,10 @@ export class SubagentRosterTracker { if (agentId === undefined) return; const entry = this.bySession.get(sessionId)?.get(agentId); if (!entry) return; + if (entry.id !== event.info.taskId) return; + entry.id = event.info.taskId; + entry.agent_id = agentId; + entry.run_in_background = true; entry.status = event.info.status === 'completed' ? 'completed' diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 3051bf3953..e08c00adbd 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -386,7 +386,8 @@ describe('SessionEventBroadcaster', () => { const detached = await bc.getSnapshotState('s1'); expect(detached.subagents).toMatchObject([ { - id: 'agent_1', + id: 'agent-task-1', + agent_id: 'agent_1', status: 'running', run_in_background: true, }, @@ -411,7 +412,8 @@ describe('SessionEventBroadcaster', () => { const stopped = await bc.getSnapshotState('s1'); expect(stopped.subagents).toMatchObject([ { - id: 'agent_1', + id: 'agent-task-1', + agent_id: 'agent_1', status: 'cancelled', subagent_phase: 'failed', run_in_background: true, @@ -448,6 +450,20 @@ describe('SessionEventBroadcaster', () => { runInBackground: true, }), ); + main.bus.emit( + agentEvent('task.started', { + info: { + taskId: 'agent-task-1', + kind: 'agent', + agentId: 'agent_1', + description: 'inspect terminal ordering', + status: 'running', + detached: true, + startedAt: Date.now(), + endedAt: null, + }, + }), + ); if (subagentEvent === 'subagent.completed') { main.bus.emit( agentEvent(subagentEvent, { @@ -481,7 +497,8 @@ describe('SessionEventBroadcaster', () => { const snapshot = await bc.getSnapshotState('s1'); expect(snapshot.subagents).toMatchObject([ { - id: 'agent_1', + id: 'agent-task-1', + agent_id: 'agent_1', status, subagent_phase: phase, }, diff --git a/packages/kap-server/test/subagentRosterTracker.test.ts b/packages/kap-server/test/subagentRosterTracker.test.ts index f280e4e98b..c555d3d1e9 100644 --- a/packages/kap-server/test/subagentRosterTracker.test.ts +++ b/packages/kap-server/test/subagentRosterTracker.test.ts @@ -39,6 +39,18 @@ function taskTerminated(status: string, agentId = 'agent_1'): Event { }); } +function taskStarted(agentId = 'agent_1'): Event { + return ev({ + type: 'task.started', + info: { + taskId: `task-${agentId}`, + kind: 'agent', + agentId, + status: 'running', + }, + }); +} + describe('SubagentRosterTracker', () => { it('records the full swarm identity on spawn', () => { const t = new SubagentRosterTracker(); @@ -88,7 +100,7 @@ describe('SubagentRosterTracker', () => { expect(resumed.suspended_reason).toBeUndefined(); }); - it('marks a foreground subagent as background when its Agent task detaches', () => { + it('uses the background task id when a foreground subagent detaches', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); t.apply( @@ -103,7 +115,11 @@ describe('SubagentRosterTracker', () => { }, }), ); - expect(t.get(SID)[0]?.run_in_background).toBe(true); + expect(t.get(SID)[0]).toMatchObject({ + id: 'agent-task-1', + agent_id: 'agent_1', + run_in_background: true, + }); }); it.each([ @@ -115,8 +131,11 @@ describe('SubagentRosterTracker', () => { ] as const)('maps Agent task terminal status %s into the snapshot', (taskStatus, status, phase) => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); + t.apply(SID, taskStarted()); t.apply(SID, taskTerminated(taskStatus)); expect(t.get(SID)[0]).toMatchObject({ + id: 'task-agent_1', + agent_id: 'agent_1', status, subagent_phase: phase, completed_at: '2023-11-14T22:13:20.000Z', @@ -126,6 +145,7 @@ describe('SubagentRosterTracker', () => { it('keeps a killed task terminal when subagent.completed arrives late', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); + t.apply(SID, taskStarted()); t.apply(SID, taskTerminated('killed')); const terminal = t.get(SID)[0]; @@ -137,6 +157,7 @@ describe('SubagentRosterTracker', () => { it.each(['timed_out', 'lost'])('keeps %s terminal across late lifecycle events', (status) => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); + t.apply(SID, taskStarted()); t.apply(SID, taskTerminated(status)); const terminal = t.get(SID)[0]; @@ -158,13 +179,14 @@ describe('SubagentRosterTracker', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); t.apply(SID, spawned({ subagentId: 'agent_2', swarmIndex: 1 })); + t.apply(SID, taskStarted()); t.apply(SID, taskTerminated('killed')); t.apply(SID, ev({ type: 'subagent.started', subagentId: 'agent_2' })); t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'ghost', resultSummary: 'late' })); expect(t.get(SID)).toMatchObject([ - { id: 'agent_1', status: 'cancelled', subagent_phase: 'failed' }, + { id: 'task-agent_1', agent_id: 'agent_1', status: 'cancelled', subagent_phase: 'failed' }, { id: 'agent_2', status: 'running', subagent_phase: 'working' }, ]); }); @@ -172,6 +194,7 @@ describe('SubagentRosterTracker', () => { it('lets a new spawn reset a terminal entry with the same agent id', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); + t.apply(SID, taskStarted()); t.apply(SID, taskTerminated('killed')); t.apply(SID, spawned({ description: 'new run', runInBackground: false })); @@ -185,6 +208,45 @@ describe('SubagentRosterTracker', () => { }); }); + it('ignores an old task termination after the same agent id starts a new run', () => { + const t = new SubagentRosterTracker(); + t.apply(SID, spawned()); + t.apply( + SID, + ev({ + type: 'task.started', + info: { taskId: 'old-task', kind: 'agent', agentId: 'agent_1' }, + }), + ); + t.apply(SID, ev({ type: 'subagent.completed', subagentId: 'agent_1', resultSummary: 'done' })); + t.apply(SID, spawned({ description: 'new run', runInBackground: false })); + t.apply( + SID, + ev({ type: 'subagent.completed', subagentId: 'agent_1', resultSummary: 'new done' }), + ); + + t.apply( + SID, + ev({ + type: 'task.terminated', + info: { + taskId: 'old-task', + kind: 'agent', + agentId: 'agent_1', + status: 'killed', + endedAt: 1_700_000_000_000, + }, + }), + ); + + expect(t.get(SID)[0]).toMatchObject({ + id: 'agent_1', + status: 'completed', + description: 'new run', + run_in_background: false, + }); + }); + it('records completion with the result summary as output preview', () => { const t = new SubagentRosterTracker(); t.apply(SID, spawned()); diff --git a/packages/protocol/src/__tests__/snapshot.test.ts b/packages/protocol/src/__tests__/snapshot.test.ts index 30e752aabd..c121a4ddfa 100644 --- a/packages/protocol/src/__tests__/snapshot.test.ts +++ b/packages/protocol/src/__tests__/snapshot.test.ts @@ -115,7 +115,8 @@ describe('rest/snapshot — session snapshot', () => { in_flight_turn: null, subagents: [ { - id: 'agent_1', + id: 'agent-task-1', + agent_id: 'agent_1', session_id: 'sess_1', kind: 'subagent', description: 'explore the auth flow', @@ -147,6 +148,7 @@ describe('rest/snapshot — session snapshot', () => { expect(result.success).toBe(true); if (result.success) { expect(result.data.subagents).toHaveLength(2); + expect(result.data.subagents?.[0]?.agent_id).toBe('agent_1'); expect(result.data.subagents?.[0]?.parent_tool_call_id).toBe('call_1'); expect(result.data.subagents?.[1]?.subagent_phase).toBe('completed'); } diff --git a/packages/protocol/src/rest/snapshot.ts b/packages/protocol/src/rest/snapshot.ts index 02278a69ef..8c992ec1b4 100644 --- a/packages/protocol/src/rest/snapshot.ts +++ b/packages/protocol/src/rest/snapshot.ts @@ -66,6 +66,8 @@ export type InFlightTurn = z.infer; * (non-replayed) `subagent.spawned` WS event. */ export const snapshotSubagentSchema = taskSchema.extend({ + /** Stable agent identity used by subagent lifecycle events after a task detaches. */ + agent_id: z.string().optional(), subagent_phase: z.enum(['queued', 'working', 'suspended', 'completed', 'failed']).optional(), subagent_type: z.string().optional(), parent_tool_call_id: z.string().optional(), From 9c5e10015be3e9744be975c0d8f945831da24d0a Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 08:52:16 +0800 Subject: [PATCH 8/9] fix(web): preserve subagent identity across refresh --- .../src/api/daemon/agentEventProjector.ts | 58 ++++-- apps/kimi-web/src/api/daemon/client.ts | 11 +- apps/kimi-web/src/api/daemon/eventReducer.ts | 41 ++++- apps/kimi-web/src/lib/taskMerge.ts | 23 +-- .../test/agent-event-projector.test.ts | 152 +++++++++++++++- apps/kimi-web/test/event-reducer.test.ts | 166 ++++++++++++++++++ apps/kimi-web/test/lib-logic.test.ts | 38 ++++ 7 files changed, 446 insertions(+), 43 deletions(-) diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index fbfc48c84a..7b06a8a4a0 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -126,6 +126,7 @@ interface SessionState { // Subagent lifecycle deltas after spawned only carry subagentId. Keep the // spawned metadata here so later updates can replace the full AppTask. subagentMeta: Map; + subagentRosterAuthoritative: boolean; } function createSessionState(): SessionState { @@ -146,6 +147,7 @@ function createSessionState(): SessionState { model: '', messages: [], subagentMeta: new Map(), + subagentRosterAuthoritative: false, }; } @@ -231,6 +233,7 @@ function patchRunningSubagent( ): AppTask | null { if (typeof subagentId !== 'string' || subagentId.length === 0) return null; const current = state.subagentMeta.get(subagentId); + if (current === undefined && state.subagentRosterAuthoritative) return null; if (current !== undefined && current.status !== 'running') return null; return patchSubagent(state, sessionId, subagentId, patch); } @@ -536,6 +539,7 @@ export interface AgentProjector { * snapshot's `session.status` is the authoritative value. */ seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; + seedSubagents(sessionId: string, tasks: AppTask[] | undefined): void; /** Reset all per-session state (call on re-subscribe / resync). */ reset(sessionId: string): void; /** @@ -609,6 +613,16 @@ export function createAgentProjector(): AgentProjector { return [{ type: 'messageCreated', message: cloneMessage(msg) }]; } + function seedSubagents(sessionId: string, tasks: AppTask[] | undefined): void { + const s = getOrCreate(sessionId); + s.subagentRosterAuthoritative = tasks !== undefined; + if (tasks === undefined) return; + for (const task of tasks) { + if (task.kind !== 'subagent') continue; + s.subagentMeta.set(task.agentId ?? task.id, task); + } + } + function project( rawType: string, payload: unknown, @@ -1132,6 +1146,8 @@ export function createAgentProjector(): AgentProjector { const agentId = typeof info.agentId === 'string' ? info.agentId : undefined; if (info.kind === 'agent' && agentId !== undefined) { const current = s.subagentMeta.get(agentId); + if (current === undefined ? s.subagentRosterAuthoritative : current.status !== 'running') break; + if (current !== undefined && current.id !== agentId && current.id !== taskId) break; const task = patchSubagent(s, sessionId, agentId, { id: taskId, status: current?.status ?? 'running', @@ -1181,28 +1197,32 @@ export function createAgentProjector(): AgentProjector { : info.status === 'killed' ? 'cancelled' : 'failed'; - const task = patchSubagent(s, sessionId, agentId, { - id: taskId.length > 0 ? taskId : agentId, + const current = s.subagentMeta.get(agentId); + if (current !== undefined && taskId.length > 0 && current.id !== taskId) break; + const completedAt = + typeof info.endedAt === 'number' + ? new Date(info.endedAt).toISOString() + : new Date().toISOString(); + const task = + current === undefined && s.subagentRosterAuthoritative + ? null + : patchSubagent(s, sessionId, agentId, { + id: taskId.length > 0 ? taskId : agentId, + status, + subagentPhase: status === 'completed' ? 'completed' : 'failed', + completedAt, + runInBackground: true, + }); + out.push({ + type: 'taskCompleted', + sessionId, + taskId: task?.id ?? (taskId || agentId), + agentId, status, subagentPhase: status === 'completed' ? 'completed' : 'failed', - completedAt: - typeof info.endedAt === 'number' - ? new Date(info.endedAt).toISOString() - : new Date().toISOString(), + completedAt: task?.completedAt ?? completedAt, runInBackground: true, }); - if (task) { - out.push({ - type: 'taskCompleted', - sessionId, - taskId: task.id, - agentId, - status, - subagentPhase: task.subagentPhase, - completedAt: task.completedAt, - runInBackground: true, - }); - } break; } const failed = @@ -1323,7 +1343,7 @@ export function createAgentProjector(): AgentProjector { return out; } - return { project, bindNextPromptId, seedInFlight, reset, markSideChannelAgent }; + return { project, bindNextPromptId, seedInFlight, seedSubagents, reset, markSideChannelAgent }; } // --------------------------------------------------------------------------- diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 70f8a0c2cc..9718b73153 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1382,12 +1382,13 @@ export class DaemonKimiWebApi implements KimiWebApi { // message list. if (snapshot.inFlightTurn === null) { projector.reset(sessionId); - return; - } - const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn); - for (const appEvent of appEvents) { - handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq }); + } else { + const appEvents = projector.seedInFlight(sessionId, snapshot.inFlightTurn); + for (const appEvent of appEvents) { + handlers.onEvent(appEvent, { sessionId, seq: snapshot.asOfSeq }); + } } + projector.seedSubagents(sessionId, snapshot.subagents); }, bindNextPromptId(sessionId: string, promptId: string): void { // Wire the real daemon prompt_id into the projector so turn.started diff --git a/apps/kimi-web/src/api/daemon/eventReducer.ts b/apps/kimi-web/src/api/daemon/eventReducer.ts index 86a3cd6b97..19e6caaeb2 100644 --- a/apps/kimi-web/src/api/daemon/eventReducer.ts +++ b/apps/kimi-web/src/api/daemon/eventReducer.ts @@ -557,7 +557,29 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; const exactIndex = list.findIndex((t) => t.id === event.task.id); - const aliasIndex = + const hasOtherActiveTask = + event.task.agentId !== undefined && + event.task.id !== event.task.agentId && + list.some( + (t) => + t.kind === 'subagent' && + t.status === 'running' && + t.agentId === event.task.agentId && + t.id !== event.task.id && + t.id !== event.task.agentId, + ); + if (hasOtherActiveTask && exactIndex === -1) break; + const activeAliasIndex = + event.task.agentId === undefined + ? -1 + : list.findIndex( + (t) => + t.status === 'running' && + (t.agentId === event.task.agentId || + (t.kind === 'subagent' && t.id === event.task.agentId)) && + (!hasOtherActiveTask || t.id === event.task.agentId), + ); + const terminalAliasIndex = event.task.agentId === undefined ? -1 : list.findIndex( @@ -565,13 +587,20 @@ export function reduceAppEvent( t.agentId === event.task.agentId || (t.kind === 'subagent' && t.id === event.task.agentId), ); + const aliasIndex = + activeAliasIndex !== -1 + ? activeAliasIndex + : exactIndex === -1 && + (event.task.subagentPhase === 'queued' || event.task.id === event.task.agentId) + ? terminalAliasIndex + : -1; const idx = exactIndex !== -1 ? exactIndex : aliasIndex; if (idx === -1) { next.tasksBySession[sid] = [...list, event.task]; } else { const patched = [...list]; const exact = exactIndex === -1 ? undefined : list[exactIndex]; - const previous = aliasIndex === -1 ? list[idx]! : list[aliasIndex]!; + const previous = activeAliasIndex !== -1 ? list[activeAliasIndex]! : list[idx]!; const keepDetachedTaskId = previous.id !== event.task.id && event.task.agentId !== undefined && @@ -635,7 +664,9 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; next.tasksBySession[sid] = list.map((t) => { - if (t.id !== event.taskId && t.agentId !== event.taskId) return t; + const exact = t.id === event.taskId; + const activeAlias = !exact && t.status === 'running' && t.agentId === event.taskId; + if (!exact && !activeAlias) return t; // Subagent streamed output (assistant.delta) concatenates into a single // growing text block rather than fragmenting each delta into its own // line — the detail panel renders it like a thinking block. @@ -661,7 +692,9 @@ export function reduceAppEvent( const sid = event.sessionId; const list = next.tasksBySession[sid] ?? []; next.tasksBySession[sid] = list.map((t) => { - if (t.id !== event.taskId && t.agentId !== event.taskId) return t; + const exact = t.id === event.taskId; + const activeAlias = !exact && t.status === 'running' && t.agentId === event.taskId; + if (!exact && !activeAlias) return t; return { ...t, id: event.agentId === undefined ? t.id : event.taskId, diff --git a/apps/kimi-web/src/lib/taskMerge.ts b/apps/kimi-web/src/lib/taskMerge.ts index 9ce253b210..7dc6caeeeb 100644 --- a/apps/kimi-web/src/lib/taskMerge.ts +++ b/apps/kimi-web/src/lib/taskMerge.ts @@ -57,20 +57,21 @@ export function mergeSnapshotSubagents( ): AppTask[] { if (roster === undefined) return existing; const existingById = new Map(existing.map((t) => [t.id, t] as const)); - const existingByAgentId = new Map( - existing - .filter((t) => t.kind === 'subagent') - .map((t) => [t.agentId ?? t.id, t] as const), - ); const rosterIds = new Set(roster.map((t) => t.id)); - const rosterAgentIds = new Set(); + const rosterAliasIds = new Set(); const merged = roster.map((task) => { const byId = existingById.get(task.id); - const byAgentId = task.agentId === undefined ? undefined : existingByAgentId.get(task.agentId); const aliasMatch = - byId === undefined && byAgentId?.status === 'running' ? byAgentId : undefined; - const live = byId ?? aliasMatch; - if (aliasMatch !== undefined && task.agentId !== undefined) rosterAgentIds.add(task.agentId); + task.agentId === undefined + ? undefined + : existing.find( + (t) => + t.kind === 'subagent' && + t.status === 'running' && + (t.agentId === task.agentId || t.id === task.agentId), + ); + const live = aliasMatch ?? byId; + if (aliasMatch !== undefined && aliasMatch.id !== task.id) rosterAliasIds.add(aliasMatch.id); if (!live) return task; return { ...task, outputLines: live.outputLines, text: live.text }; }); @@ -78,7 +79,7 @@ export function mergeSnapshotSubagents( (t) => (t.kind !== 'subagent' || t.runInBackground === true) && !rosterIds.has(t.id) && - !rosterAgentIds.has(t.agentId ?? t.id), + !rosterAliasIds.has(t.id), ); if (merged.length === 0 && kept.length === existing.length) return existing; return kept.length === 0 ? merged : [...merged, ...kept]; diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index 58fe37dd52..98e9696da5 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -138,6 +138,11 @@ describe('detached Agent task projection', () => { ] as const)('maps Agent task terminal status %s', (taskStatus, status, phase) => { const projector = createAgentProjector(); projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); const events = projector.project( 'task.terminated', @@ -192,14 +197,153 @@ describe('detached Agent task projection', () => { expect.objectContaining({ type: 'taskCompleted', status: 'cancelled' }), ); - expect(projector.project('task.started', { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, 's1')).toContainEqual( + expect(projector.project('task.started', { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, 's1')).toEqual([]); + expect(projector.project('subagent.completed', { subagentId: 'agent-1', resultSummary: 'late' }, 's1')).toEqual([]); + expect(projector.project('subagent.started', { subagentId: 'agent-1' }, 's1')).toEqual([]); + }); + + it('allows task.started after a new spawned run resets a terminal Agent', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'old-task', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); + projector.project( + 'task.terminated', + { info: { taskId: 'old-task', agentId: 'agent-1', kind: 'agent', status: 'killed' } }, + 's1', + ); + projector.project('subagent.spawned', { subagentId: 'agent-1', description: 'new run' }, 's1'); + + expect( + projector.project( + 'task.started', + { info: { taskId: 'new-task', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ), + ).toContainEqual( expect.objectContaining({ type: 'taskCreated', - task: expect.objectContaining({ id: 'agent-task-1', status: 'cancelled' }), + task: expect.objectContaining({ id: 'new-task', status: 'running', runInBackground: true }), + }), + ); + }); + + it('seeds snapshot ownership so a late detach task.started updates the foreground row', () => { + const projector = createAgentProjector(); + projector.seedSubagents('s1', [ + { + id: 'agent-1', + agentId: 'agent-1', + sessionId: 's1', + kind: 'subagent', + description: 'Review files', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + runInBackground: false, + }, + ]); + + expect( + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ), + ).toContainEqual( + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ id: 'agent-task-1', agentId: 'agent-1', runInBackground: true }), + }), + ); + }); + + it('ignores a late task.started after a terminal snapshot seed', () => { + const projector = createAgentProjector(); + projector.seedSubagents('s1', [ + { + id: 'agent-task-1', + agentId: 'agent-1', + sessionId: 's1', + kind: 'subagent', + description: 'Review files', + status: 'cancelled', + subagentPhase: 'failed', + createdAt: '2026-01-01T00:00:00.000Z', + runInBackground: true, + }, + ]); + + expect( + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ), + ).toEqual([]); + }); + + it('ignores an old task.started after a newer detached task owns the run', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'new-task', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); + + expect( + projector.project( + 'task.started', + { info: { taskId: 'old-task', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ), + ).toEqual([]); + }); + + it('ignores an old task termination after a new spawned run', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.project( + 'task.started', + { info: { taskId: 'old-task', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ); + projector.project('subagent.spawned', { subagentId: 'agent-1', description: 'new run' }, 's1'); + + expect( + projector.project( + 'task.terminated', + { info: { taskId: 'old-task', agentId: 'agent-1', kind: 'agent', status: 'killed' } }, + 's1', + ), + ).toEqual([]); + }); + + it('keeps legacy lifecycle recovery when an older snapshot omits the roster', () => { + const projector = createAgentProjector(); + projector.seedSubagents('s1', undefined); + + expect(projector.project('subagent.started', { subagentId: 'agent-1' }, 's1')).toContainEqual( + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ id: 'agent-1', status: 'running' }), + }), + ); + expect( + projector.project( + 'task.started', + { info: { taskId: 'agent-task-1', agentId: 'agent-1', kind: 'agent' } }, + 's1', + ), + ).toContainEqual( + expect.objectContaining({ + type: 'taskCreated', + task: expect.objectContaining({ id: 'agent-task-1', runInBackground: true }), }), ); - expect(projector.project('subagent.completed', { subagentId: 'agent-1', resultSummary: 'late' }, 's1')).toEqual([]); - expect(projector.project('subagent.started', { subagentId: 'agent-1' }, 's1')).toEqual([]); }); }); diff --git a/apps/kimi-web/test/event-reducer.test.ts b/apps/kimi-web/test/event-reducer.test.ts index 971d581bc0..0d98b3bd0e 100644 --- a/apps/kimi-web/test/event-reducer.test.ts +++ b/apps/kimi-web/test/event-reducer.test.ts @@ -382,6 +382,172 @@ describe('reduceAppEvent taskProgress', () => { }); }); + it('uses the active alias instead of terminal history when a detached task arrives', () => { + const oldRun = { + ...makeSubagentTask('agent-task-old', 's1'), + agentId: 'agent-1', + status: 'completed' as const, + subagentPhase: 'completed' as const, + runInBackground: true, + }; + const foreground = { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + parentToolCallId: 'call-1', + status: 'running' as const, + runInBackground: false, + }; + const state = { + ...createInitialState(), + tasksBySession: { s1: [oldRun, foreground] }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...makeSubagentTask('agent-task-new', 's1'), + agentId: 'agent-1', + status: 'running', + runInBackground: true, + }, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toHaveLength(2); + expect(next.tasksBySession.s1).toContainEqual( + expect.objectContaining({ + id: 'agent-task-old', + status: 'completed', + }), + ); + expect(next.tasksBySession.s1).toContainEqual( + expect.objectContaining({ + id: 'agent-task-new', + agentId: 'agent-1', + status: 'running', + parentToolCallId: 'call-1', + }), + ); + expect(next.tasksBySession.s1).not.toContainEqual( + expect.objectContaining({ id: 'agent-1' }), + ); + }); + + it('does not append new-run progress to a terminal task with the same agent id', () => { + const state = { + ...createInitialState(), + tasksBySession: { + s1: [ + { + ...makeSubagentTask('agent-task-old', 's1'), + agentId: 'agent-1', + status: 'completed' as const, + subagentPhase: 'completed' as const, + text: 'OLD', + runInBackground: true, + }, + { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + text: 'NEW', + runInBackground: false, + }, + ], + }, + }; + + const next = reduceAppEvent( + state, + { type: 'taskProgress', sessionId: 's1', taskId: 'agent-1', outputChunk: '!', stream: 'stdout', kind: 'text' }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toContainEqual(expect.objectContaining({ id: 'agent-task-old', text: 'OLD' })); + expect(next.tasksBySession.s1).toContainEqual(expect.objectContaining({ id: 'agent-1', text: 'NEW!' })); + }); + + it('does not complete terminal history when a lifecycle alias targets the active run', () => { + const state = { + ...createInitialState(), + tasksBySession: { + s1: [ + { + ...makeSubagentTask('agent-task-old', 's1'), + agentId: 'agent-1', + status: 'completed' as const, + subagentPhase: 'completed' as const, + runInBackground: true, + }, + { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + runInBackground: false, + }, + ], + }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCompleted', + sessionId: 's1', + taskId: 'agent-1', + agentId: 'agent-1', + status: 'completed', + subagentPhase: 'completed', + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toContainEqual( + expect.objectContaining({ id: 'agent-task-old', status: 'completed' }), + ); + expect(next.tasksBySession.s1).toContainEqual( + expect.objectContaining({ id: 'agent-1', status: 'completed' }), + ); + }); + + it('does not relabel a newer detached task from a stale task id event', () => { + const state = { + ...createInitialState(), + tasksBySession: { + s1: [ + { + ...makeSubagentTask('agent-task-new', 's1'), + agentId: 'agent-1', + runInBackground: true, + }, + { + ...makeSubagentTask('agent-1', 's1'), + agentId: 'agent-1', + runInBackground: false, + }, + ], + }, + }; + + const next = reduceAppEvent( + state, + { + type: 'taskCreated', + sessionId: 's1', + task: { + ...makeSubagentTask('agent-task-old', 's1'), + agentId: 'agent-1', + runInBackground: true, + }, + }, + { sessionId: 's1', seq: 1 }, + ); + + expect(next.tasksBySession.s1).toEqual(state.tasksBySession.s1); + }); + it('keeps a terminal snapshot state across a late lifecycle alias', () => { const state = { ...createInitialState(), diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index 372a1437f2..d3ffb3ad37 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -678,6 +678,43 @@ describe('mergeSnapshotSubagents', () => { expect(merged).toEqual([resumed, oldRun]); }); + + it('removes a stale foreground alias when the roster already has the detached task id', () => { + const detached = subagent('agent-task-new', { + agentId: 'agent-1', + runInBackground: true, + }); + const foreground = subagent('agent-1', { + agentId: 'agent-1', + runInBackground: false, + }); + + expect(mergeSnapshotSubagents([detached], [detached, foreground])).toEqual([detached]); + }); + + it('matches a running alias even when terminal history appears later in the list', () => { + const foreground = subagent('agent-1', { + agentId: 'agent-1', + status: 'running', + runInBackground: false, + text: 'current output', + }); + const oldRun = subagent('agent-task-old', { + agentId: 'agent-1', + status: 'completed', + runInBackground: true, + text: 'old output', + }); + const rosterEntry = subagent('agent-task-new', { + agentId: 'agent-1', + runInBackground: true, + }); + + const merged = mergeSnapshotSubagents([rosterEntry], [foreground, oldRun]); + + expect(merged).toContainEqual(expect.objectContaining({ id: 'agent-task-new', text: 'current output' })); + expect(merged).toContainEqual(expect.objectContaining({ id: 'agent-task-old', text: 'old output' })); + }); }); describe('keepLiveSubagents', () => { @@ -721,4 +758,5 @@ describe('keepLiveSubagents', () => { }), ]); }); + }); From d2d8e2f77851d81c02d0f8f695d3d48818a06dbd Mon Sep 17 00:00:00 2001 From: qer Date: Tue, 14 Jul 2026 09:12:20 +0800 Subject: [PATCH 9/9] fix(web): close stale roster refresh races --- .../src/api/daemon/agentEventProjector.ts | 1 + apps/kimi-web/src/lib/taskMerge.ts | 2 +- apps/kimi-web/test/agent-event-projector.test.ts | 9 +++++++++ apps/kimi-web/test/lib-logic.test.ts | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index 7b06a8a4a0..e19ca7f4fa 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -617,6 +617,7 @@ export function createAgentProjector(): AgentProjector { const s = getOrCreate(sessionId); s.subagentRosterAuthoritative = tasks !== undefined; if (tasks === undefined) return; + s.subagentMeta.clear(); for (const task of tasks) { if (task.kind !== 'subagent') continue; s.subagentMeta.set(task.agentId ?? task.id, task); diff --git a/apps/kimi-web/src/lib/taskMerge.ts b/apps/kimi-web/src/lib/taskMerge.ts index 7dc6caeeeb..440bd61e51 100644 --- a/apps/kimi-web/src/lib/taskMerge.ts +++ b/apps/kimi-web/src/lib/taskMerge.ts @@ -38,7 +38,7 @@ export function keepLiveSubagents(restBased: AppTask[], existing: AppTask[]): Ap const liveSubagents = existing.filter( (t) => t.kind === 'subagent' && - t.runInBackground !== true && + (t.runInBackground !== true || t.status === 'running') && !restIds.has(t.id), ); return liveSubagents.length === 0 ? mergedRest : [...mergedRest, ...liveSubagents]; diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index 98e9696da5..51684b4c58 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -322,6 +322,15 @@ describe('detached Agent task projection', () => { ).toEqual([]); }); + it('drops projector members omitted by an authoritative snapshot', () => { + const projector = createAgentProjector(); + projector.project('subagent.spawned', { subagentId: 'agent-1' }, 's1'); + projector.seedSubagents('s1', []); + + expect(projector.project('subagent.started', { subagentId: 'agent-1' }, 's1')).toEqual([]); + expect(projector.project('assistant.delta', { agentId: 'agent-1', delta: 'late' }, 's1')).toEqual([]); + }); + it('keeps legacy lifecycle recovery when an older snapshot omits the roster', () => { const projector = createAgentProjector(); projector.seedSubagents('s1', undefined); diff --git a/apps/kimi-web/test/lib-logic.test.ts b/apps/kimi-web/test/lib-logic.test.ts index d3ffb3ad37..fd5dfd4cd3 100644 --- a/apps/kimi-web/test/lib-logic.test.ts +++ b/apps/kimi-web/test/lib-logic.test.ts @@ -759,4 +759,20 @@ describe('keepLiveSubagents', () => { ]); }); + it('keeps a newly detached running row while a stale REST poll catches up', () => { + const detached: AppTask = { + id: 'agent-task-1', + sessionId: 's1', + kind: 'subagent', + description: 'Review files', + status: 'running', + createdAt: '2026-01-01T00:00:00.000Z', + agentId: 'agent-1', + runInBackground: true, + subagentPhase: 'working', + }; + + expect(keepLiveSubagents([], [detached])).toEqual([detached]); + }); + });