Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-subagent-turn-status-notifications.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve idle transition after subagent-only runs

If a client first subscribes or resyncs while only a background/sub-agent turn is active, ensureState still seeds lastStatus from ISessionActivity.status(), which reports running for any active agent. With this new main-agent guard, that sub-agent's later turn.ended is journaled but no status_changed(idle) is enqueued, so v1 clients that received a running snapshot can stay busy and keep subsequent prompts queued until some later main-agent status transition. Please make the initial/snapshot status use the same main-agent-only rule, or still clear a subagent-only running state without firing the premature main-turn completion path.

Useful? React with 👍 / 👎.

// Emit completion after the turn event. Pending interactions remain higher
// priority; otherwise failed/cancelled/blocked turns abort and all others
// become idle.
Expand Down
50 changes: 46 additions & 4 deletions packages/kap-server/test/sessionEventBroadcaster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
});
Expand Down Expand Up @@ -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);
Expand Down
Loading