diff --git a/.changeset/fix-subagent-turn-status-notifications.md b/.changeset/fix-subagent-turn-status-notifications.md new file mode 100644 index 0000000000..f2818c8dca --- /dev/null +++ b/.changeset/fix-subagent-turn-status-notifications.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix sub-agent completions being signaled as session turn completions, which fired premature completion notifications, sounds, and unread markers while the main turn was still running. diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 6025c4d1f2..b7b443f0d1 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -539,7 +539,14 @@ export class SessionEventBroadcaster { // `DomainEventMap` payload types are deliberately wider than the protocol // contract, hence the assertion via `unknown`. const wireEvent = { ...event, agentId, sessionId } as unknown as Event; - if (event.type === 'turn.started') { + // Session status transitions are synthesized only from the MAIN agent's turn + // boundaries. Subagents share the session channel (their frames carry their + // own agentId and clients route them to the task view), so a subagent's + // turn.ended would otherwise emit a bogus `status_changed(idle)` mid-turn: + // the client reads that as "the turn finished" (driving notifications, + // sounds, unread dots and the queued-message drain), and the real turn end + // is then swallowed by dedup. Same main-only rule as InFlightTurnTracker. + if (agentId === MAIN_AGENT_ID && event.type === 'turn.started') { // Status is authoritative protocol state: emit it before the turn event so // clients enter running first. A pending interaction remains higher // priority than running. @@ -549,7 +556,7 @@ export class SessionEventBroadcaster { state.queue = state.queue .then(() => this.dispatch(state, wireEvent, volatile)) .catch(() => {}); - if (event.type === 'turn.ended') { + if (agentId === MAIN_AGENT_ID && event.type === 'turn.ended') { // Emit completion after the turn event. Pending interactions remain higher // priority; otherwise failed/cancelled/blocked turns abort and all others // become idle. diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index e96e9c133f..0233a9de62 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -503,6 +503,44 @@ describe('SessionEventBroadcaster', () => { ]); }); + it('does not synthesize session status from sub-agent turn boundaries', async () => { + // Regression: a sub-agent's turn.started/turn.ended stream over the same + // session channel with their own agentId. Synthesizing status transitions + // from them emitted a bogus `status_changed(idle)` the moment a foreground + // sub-agent finished — mid main turn — which kimi-web reads as "the turn + // finished" (browser notification, completion sound, unread dot, queued + // message drain), while the real main-agent turn end was then swallowed by + // dedup and never notified. + const lc = new FakeLifecycle(); + const main = lc.addAgent('main'); + const sub = lc.addAgent('agent-0'); + sessions.set('s1', lc); + const { target, envelopes } = collectingTarget(); + await bc.subscribe('s1', target); + + main.bus.emit(agentEvent('turn.started', { turnId: 1 })); + // A foreground sub-agent runs and completes while the main turn is in flight. + sub.bus.emit(agentEvent('turn.started', { turnId: 10 })); + sub.bus.emit(agentEvent('turn.ended', { turnId: 10, reason: 'completed' })); + main.bus.emit(agentEvent('turn.ended', { turnId: 1, reason: 'completed' })); + await bc.getCursor('s1'); + + // The sub-agent's turn events are still fanned out (clients render them in + // the task view), but they produce no status transitions. + expect( + envelopes + .filter((e) => e.type === 'turn.started' || e.type === 'turn.ended') + .map((e) => (e.payload as { agentId: string }).agentId), + ).toEqual(['main', 'agent-0', 'agent-0', 'main']); + const statusEnvs = envelopes.filter((e) => e.type === 'event.session.status_changed'); + expect(statusEnvs.map((e) => e.payload)).toMatchObject([ + { status: 'running', previous_status: 'idle' }, + { status: 'idle', previous_status: 'running' }, + ]); + // The idle transition fires exactly once, after the main agent's turn end. + expect(envelopes.at(-1)!.type).toBe('event.session.status_changed'); + }); + it('broadcasts question requested / answered as durable v1 events', async () => { const lc = new FakeLifecycle(); lc.addAgent('main'); @@ -795,7 +833,10 @@ describe('SessionEventBroadcaster', () => { agentEnvs.every((e) => (e.payload as { agentId: string }).agentId === 'main'), ).toBe(true); // `event.session.status_changed` is global (`event.session.*`) and bypasses - // the agent filter. The redundant idle transition from the sub-agent is deduped. + // the agent filter. The sub-agent's turn.ended synthesizes no status change + // at all (main-only rule — see the "does not synthesize session status from + // sub-agent turn boundaries" test), so only the main agent's two transitions + // are delivered. const statusEnvs = envelopes.filter((e) => e.type === 'event.session.status_changed'); expect(statusEnvs).toHaveLength(2); }); @@ -870,9 +911,10 @@ describe('SessionEventBroadcaster', () => { const result = await bc2.getBufferedSince('s1', { seq: 0 }, new Set(['main'])); expect(result.resyncRequired).toBe(false); - // The sub-agent's turn events are cropped, while global status transitions - // retain their original positions in the session sequence. - expect(result.events.map((e) => e.seq)).toEqual([1, 2, 3, 4, 5, 8, 9, 10, 11, 12]); + // The sub-agent's turn events are cropped (seq 5/6 — they synthesize no + // status change), while the main agent's turns and the global status + // transitions retain their original positions in the session sequence. + expect(result.events.map((e) => e.seq)).toEqual([1, 2, 3, 4, 7, 8, 9, 10]); expect( result.events.every((e) => (e.envelope.payload as { agentId: string }).agentId === 'main'), ).toBe(true);