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-web-duplicate-turn-notification.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

web: Fix the "Turn finished" desktop notification and completion sound firing twice per turn.
44 changes: 14 additions & 30 deletions apps/kimi-web/src/api/daemon/agentEventProjector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down
11 changes: 6 additions & 5 deletions apps/kimi-web/src/api/daemon/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 4 additions & 3 deletions apps/kimi-web/src/composables/client/useWorkspaceState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 45 additions & 0 deletions apps/kimi-web/test/agent-event-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }),
}),
);
});
});
Loading