diff --git a/.changeset/fix-web-duplicate-turn-notification.md b/.changeset/fix-web-duplicate-turn-notification.md new file mode 100644 index 0000000000..425bfcc9b3 --- /dev/null +++ b/.changeset/fix-web-duplicate-turn-notification.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn. diff --git a/apps/kimi-web/src/api/daemon/agentEventProjector.ts b/apps/kimi-web/src/api/daemon/agentEventProjector.ts index a63d781cfc..6f328966c0 100644 --- a/apps/kimi-web/src/api/daemon/agentEventProjector.ts +++ b/apps/kimi-web/src/api/daemon/agentEventProjector.ts @@ -499,10 +499,11 @@ export interface AgentProjector { /** * Seed mid-turn state from a session snapshot's `in_flight_turn` (v2 sync): * resets per-session state, builds the partially-streamed assistant message - * (thinking + text + running tool_use parts), and returns the AppEvents - * (sessionStatusChanged + messageCreated) to apply to the reducer. Live - * deltas continue appending; their wire `offset` aligns against the seeded - * text so the overlap window around snapshot/subscribe is exact. + * (thinking + text + running tool_use parts), and returns the messageCreated + * AppEvent to apply to the reducer. Live deltas continue appending; their + * wire `offset` aligns against the seeded text so the overlap window around + * snapshot/subscribe is exact. Session status is NOT seeded here — the REST + * snapshot's `session.status` is the authoritative value. */ seedInFlight(sessionId: string, turn: AppInFlightTurn): AppEvent[]; /** Reset all per-session state (call on re-subscribe / resync). */ @@ -574,16 +575,7 @@ export function createAgentProjector(): AgentProjector { s.turnTextLen = turn.assistantText.length; s.turnThinkLen = turn.thinkingText.length; - return [ - { - type: 'sessionStatusChanged', - sessionId, - status: 'running', - previousStatus: 'idle', - currentPromptId: promptId, - }, - { type: 'messageCreated', message: cloneMessage(msg) }, - ]; + return [{ type: 'messageCreated', message: cloneMessage(msg) }]; } function project( @@ -701,6 +693,12 @@ export function createAgentProjector(): AgentProjector { // ----------------------------------------------------------------------- case 'turn.started': { // Bind turnId → promptId. Generate a synthetic one if none was pre-bound. + // Session status is intentionally NOT projected here — the daemon's + // `event.session.status_changed` is the single source of status + // transitions (it carries the authoritative previousStatus / + // currentPromptId and dedupes per real transition); projecting a + // second running/idle event per turn from the raw stream made every + // turn-end consumer (notifications, sounds) fire twice. const turnId: number = p?.turnId; const existingPromptId = s.currentPromptId ?? ulid('pr_'); s.currentPromptId = existingPromptId; @@ -710,14 +708,6 @@ export function createAgentProjector(): AgentProjector { // Fresh turn → fresh per-turn stream offsets. s.turnTextLen = 0; s.turnThinkLen = 0; - - out.push({ - type: 'sessionStatusChanged', - sessionId, - status: 'running', - previousStatus: 'idle', - currentPromptId: existingPromptId, - }); break; } @@ -973,14 +963,8 @@ export function createAgentProjector(): AgentProjector { const usageSnapshot = buildUsageSnapshot(s); out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot }); - const newStatus = - reason === 'cancelled' || reason === 'failed' || reason === 'filtered' ? 'aborted' : 'idle'; - out.push({ - type: 'sessionStatusChanged', - sessionId, - status: newStatus, - previousStatus: 'running', - }); + // No sessionStatusChanged here — see turn.started. The daemon's + // `event.session.status_changed` flips the session to idle/aborted. // Clear per-turn state. Reset the stream offsets too so a stale length // from this turn can't wedge the next turn's delta alignment into a diff --git a/apps/kimi-web/src/api/daemon/client.ts b/apps/kimi-web/src/api/daemon/client.ts index 7249b804ae..523c44199a 100644 --- a/apps/kimi-web/src/api/daemon/client.ts +++ b/apps/kimi-web/src/api/daemon/client.ts @@ -1355,11 +1355,12 @@ export class DaemonKimiWebApi implements KimiWebApi { }, seedSnapshot(sessionId: string, snapshot: AppSessionSnapshot): void { // Rebuild the projector's mid-turn state from the snapshot. The - // resulting AppEvents (running status + partially-streamed assistant - // message) flow through the SAME onEvent path as live events, so the - // rendering layer needs no special handling. When there is no - // in-flight turn we only reset, so stale turn state can't leak into - // the freshly-loaded message list. + // resulting AppEvent (the partially-streamed assistant message) flows + // through the SAME onEvent path as live events, so the rendering layer + // needs no special handling; session status comes from the snapshot's + // authoritative session record. When there is no in-flight turn we + // only reset, so stale turn state can't leak into the freshly-loaded + // message list. if (snapshot.inFlightTurn === null) { projector.reset(sessionId); return; diff --git a/apps/kimi-web/src/composables/client/useWorkspaceState.ts b/apps/kimi-web/src/composables/client/useWorkspaceState.ts index 0c7af88043..b9f08549a8 100644 --- a/apps/kimi-web/src/composables/client/useWorkspaceState.ts +++ b/apps/kimi-web/src/composables/client/useWorkspaceState.ts @@ -1225,9 +1225,10 @@ export function useWorkspaceState(rawState: ExtendedState, deps: UseWorkspaceSta }); // Bind the real daemon prompt_id into the event projector so the upcoming - // turn.started uses it (instead of synthesizing a random one). This is what - // makes Stop work on the real daemon: session.currentPromptId then matches - // the prompt_id the REST :abort endpoint expects. + // turn.started stamps this turn's messages with it (instead of a synthetic + // pr_ id the daemon rejects on :abort). Stop's authoritative prompt_id + // comes from the submit response above and the daemon's + // event.session.status_changed — this binding is for transcript grouping. getEventConn()?.bindNextPromptId(sid, result.promptId); // NOTE: we no longer set a local auto-title here. The daemon generates a diff --git a/apps/kimi-web/test/agent-event-projector.test.ts b/apps/kimi-web/test/agent-event-projector.test.ts index f6ffa0bdb2..71167a3aaa 100644 --- a/apps/kimi-web/test/agent-event-projector.test.ts +++ b/apps/kimi-web/test/agent-event-projector.test.ts @@ -134,3 +134,48 @@ describe('classifyFrame cron.fired', () => { expect(classifyFrame('event.cron.fired', payload)).toEqual({ route: 'agent', agentType: 'cron.fired' }); }); }); + +// Session status has a single source: the daemon's event.session.status_changed +// (mapped by toAppEvent). The raw turn stream must NOT project a second +// sessionStatusChanged per transition — when it did, every turn end fired +// turn-end consumers (completion notification, sound) twice. +describe('session status single-sourcing', () => { + it('turn.started projects no sessionStatusChanged', () => { + const projector = createAgentProjector(); + const events = projector.project('turn.started', { turnId: 1 }, 's1'); + expect(events.some((e) => e.type === 'sessionStatusChanged')).toBe(false); + }); + + it('turn.ended finalizes the message and usage but projects no sessionStatusChanged', () => { + const projector = createAgentProjector(); + projector.project('turn.started', { turnId: 1 }, 's1'); + projector.project('turn.step.started', { turnId: 1, step: 1 }, 's1'); + const events = projector.project( + 'turn.ended', + { turnId: 1, reason: 'completed', durationMs: 123 }, + 's1', + ); + expect(events.some((e) => e.type === 'sessionStatusChanged')).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ type: 'messageUpdated', status: 'completed', durationMs: 123 }), + ); + expect(events).toContainEqual(expect.objectContaining({ type: 'sessionUsageUpdated' })); + }); + + it('seedInFlight returns only the seeded message — status comes from the snapshot', () => { + const projector = createAgentProjector(); + const events = projector.seedInFlight('s1', { + turnId: 1, + assistantText: 'partial', + thinkingText: '', + runningTools: [], + }); + expect(events.some((e) => e.type === 'sessionStatusChanged')).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'messageCreated', + message: expect.objectContaining({ role: 'assistant' }), + }), + ); + }); +});