Skip to content

Commit df75a0f

Browse files
authored
refactor(agent-core-v2): derive session busy from agent activity (#1751)
1 parent e885aec commit df75a0f

114 files changed

Lines changed: 2969 additions & 2477 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@moonshot-ai/kimi-code": patch
3+
---
4+
5+
web: Keep session activity indicators in sync with agent work, prevent duplicate streamed content after session activation races or LLM retries, and flush durable session events promptly.

apps/kimi-web/src/App.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -799,10 +799,11 @@ function openPr(url: string): void {
799799
:pending-question-actions="client.pendingQuestionActions"
800800
:pending-approval-actions="client.pendingApprovalActions"
801801
:running="running"
802+
:turn-active="client.turnActive.value"
802803
:queued="client.queued.value"
803804
:search-files="client.searchFiles"
804805
:upload-image="client.uploadImage"
805-
:sending="client.isSending.value"
806+
:working="client.working.value"
806807
:starting="client.isStartingFirstPrompt.value"
807808
:fast-moon="client.fastMoon.value"
808809
:file-reload-key="client.activeSessionId.value"

apps/kimi-web/src/api/daemon/agentEventProjector.ts

Lines changed: 98 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ const MAIN_AGENT_TRANSCRIPT_FRAMES = new Set<string>([
5757
'tool.result',
5858
'agent.status.updated',
5959
'prompt.completed',
60+
'prompt.aborted',
6061
'error',
6162
]);
6263

@@ -126,6 +127,10 @@ interface SessionState {
126127
// Subagent lifecycle deltas after spawned only carry subagentId. Keep the
127128
// spawned metadata here so later updates can replace the full AppTask.
128129
subagentMeta: Map<string, AppTask>;
130+
131+
// Bubble cleared by turn.step.retrying, to be reused by the retried
132+
// step.started (same turn) instead of stacking a new bubble.
133+
retryReuseMsgId: string | undefined;
129134
}
130135

131136
function createSessionState(): SessionState {
@@ -146,6 +151,7 @@ function createSessionState(): SessionState {
146151
model: '',
147152
messages: [],
148153
subagentMeta: new Map(),
154+
retryReuseMsgId: undefined,
149155
};
150156
}
151157

@@ -696,12 +702,11 @@ export function createAgentProjector(): AgentProjector {
696702
// -----------------------------------------------------------------------
697703
case 'turn.started': {
698704
// Bind turnId → promptId. Generate a synthetic one if none was pre-bound.
699-
// Session status is intentionally NOT projected here — the daemon's
700-
// `event.session.status_changed` is the single source of status
701-
// transitions (it carries the authoritative previousStatus /
702-
// currentPromptId and dedupes per real transition); projecting a
703-
// second running/idle event per turn from the raw stream made every
704-
// turn-end consumer (notifications, sounds) fire twice.
705+
// Session busy is intentionally NOT projected here — the daemon's
706+
// `event.session.work_changed` is the single source of the busy fact
707+
// (it re-reads the authoritative drain registry and dedupes per real
708+
// transition); projecting a second busy flip per turn from the raw
709+
// stream made every turn-end consumer fire twice.
705710
const turnId: number = p?.turnId;
706711
const existingPromptId = s.currentPromptId ?? ulid('pr_');
707712
s.currentPromptId = existingPromptId;
@@ -711,6 +716,9 @@ export function createAgentProjector(): AgentProjector {
711716
// Fresh turn → fresh step stream offsets.
712717
s.turnTextLen = 0;
713718
s.turnThinkLen = 0;
719+
// Main-conversation liveness (the moon) keys off the main agent's turn
720+
// boundary directly — only main-agent frames reach this switch arm.
721+
out.push({ type: 'turnActiveChanged', sessionId, active: true });
714722
break;
715723
}
716724

@@ -733,6 +741,17 @@ export function createAgentProjector(): AgentProjector {
733741
s.turnTextLen = 0;
734742
s.turnThinkLen = 0;
735743

744+
// A retry continuation: refill the bubble turn.step.retrying cleared,
745+
// instead of creating a second bubble with the same step's content.
746+
if (s.retryReuseMsgId !== undefined) {
747+
const reuseId = s.retryReuseMsgId;
748+
s.retryReuseMsgId = undefined;
749+
if (getMsgById(s, reuseId) !== undefined) {
750+
s.currentAssistantMsgId = reuseId;
751+
break;
752+
}
753+
}
754+
736755
// Create a new pending assistant message
737756
const msg = startAssistantMessage(s, sessionId, promptId);
738757
s.currentAssistantMsgId = msg.id;
@@ -953,6 +972,16 @@ export function createAgentProjector(): AgentProjector {
953972
const reason: string = p?.reason ?? 'completed';
954973
const durationMs = numberField(p ?? {}, 'durationMs');
955974

975+
// Main-conversation liveness: the prompt this turn served is done.
976+
// This — not the session-busy status — is what ends the working moon.
977+
// It MUST be emitted first in this arm: the onMainTurnEnd side effect
978+
// gates on `seq > lastSeqBySession`, and sibling events in this arm
979+
// advance that cursor — emitted after them, this event would compare
980+
// equal and the prompt-finish cleanup (moon, queue drain) would never
981+
// fire (observed: moon stuck when a turn ends with background tasks
982+
// still running, where no work_changed(busy:false) fallback exists).
983+
out.push({ type: 'turnActiveChanged', sessionId, active: false, reason: p?.reason });
984+
956985
if (msgId) {
957986
finishAssistantMessage(s, msgId);
958987
const msg = getMsgById(s, msgId);
@@ -972,30 +1001,86 @@ export function createAgentProjector(): AgentProjector {
9721001
const usageSnapshot = buildUsageSnapshot(s);
9731002
out.push({ type: 'sessionUsageUpdated', sessionId, usage: usageSnapshot });
9741003

975-
// No sessionStatusChanged here — see turn.started. The daemon's
976-
// `event.session.status_changed` flips the session to idle/aborted.
1004+
// No busy projection here — see turn.started. The daemon's
1005+
// `event.session.work_changed` flips the session busy fact.
9771006

9781007
// Clear per-turn state. Reset the stream offsets too so a stale length
9791008
// from this turn can't wedge the next turn's delta alignment into a
980-
// silent skip if its turn.started is missed across a reconnect.
1009+
// silent skip if its turn.started is missed across a reconnect. The
1010+
// retry reuse target is per-turn as well: if the turn died between
1011+
// turn.step.retrying and the retried step.started, the next prompt
1012+
// must open a fresh bubble, not refill this turn's emptied one.
9811013
s.currentAssistantMsgId = undefined;
9821014
s.currentPromptId = undefined;
9831015
s.turnTextLen = 0;
9841016
s.turnThinkLen = 0;
1017+
s.retryReuseMsgId = undefined;
9851018
break;
9861019
}
9871020

9881021
// -----------------------------------------------------------------------
9891022
case 'prompt.completed': {
990-
// No-op at AppEvent level — turn.ended already handles the transition to idle
1023+
// No state change at AppEvent level — turn.ended / the session
1024+
// status_changed ahead of this event already finished the prompt. The
1025+
// event rides along so the web layer can spot the one case that has no
1026+
// turn-level signal: a prompt blocked before any turn started (reason
1027+
// 'blocked'), which would otherwise pin the in-flight state forever.
1028+
const promptId: string | undefined = p?.promptId;
1029+
if (typeof promptId === 'string' && promptId.length > 0) {
1030+
out.push({ type: 'promptCompleted', sessionId, promptId, reason: p?.reason ?? 'completed' });
1031+
}
9911032
break;
9921033
}
9931034

9941035
// -----------------------------------------------------------------------
995-
case 'turn.step.retrying':
1036+
case 'prompt.aborted': {
1037+
// Fires both for an active-turn abort (a turn.ended + status_changed
1038+
// precede it — the prompt is already finished) and for a QUEUED prompt
1039+
// that never started a turn (no turn events, no status flip). The web
1040+
// layer keys on promptId to clear the in-flight state in the latter case.
1041+
const promptId: string | undefined = p?.promptId;
1042+
if (typeof promptId === 'string' && promptId.length > 0) {
1043+
out.push({ type: 'promptAborted', sessionId, promptId });
1044+
}
1045+
break;
1046+
}
1047+
1048+
// -----------------------------------------------------------------------
1049+
case 'turn.step.retrying': {
1050+
// The step's stream restarts from offset 0. Reuse the abandoned
1051+
// bubble instead of stacking a new one: strip its streamed parts and
1052+
// keep the id in retryReuseMsgId so the retried step.started refills
1053+
// it in place. Otherwise the failed attempt's partial bubble stays
1054+
// rendered next to the retry's full stream — the "text/tool shown
1055+
// twice" duplication (far more visible since the retry budget grew).
1056+
const msgId = s.currentAssistantMsgId;
1057+
if (msgId !== undefined) {
1058+
const msg = getMsgById(s, msgId);
1059+
if (msg !== undefined) {
1060+
msg.content = msg.content.filter(
1061+
(c) => c.type !== 'text' && c.type !== 'thinking' && c.type !== 'toolUse',
1062+
);
1063+
out.push({
1064+
type: 'messageUpdated',
1065+
sessionId,
1066+
messageId: msgId,
1067+
content: msg.content.map((c) => ({ ...c })),
1068+
status: 'pending',
1069+
});
1070+
s.retryReuseMsgId = msgId;
1071+
}
1072+
}
1073+
s.turnTextLen = 0;
1074+
s.turnThinkLen = 0;
1075+
s.toolStartTimes.clear();
1076+
break;
1077+
}
1078+
9961079
case 'turn.step.interrupted': {
997-
// Discard current assistant message; next step.started will create a new one
1080+
// Discard current assistant message; next step.started will create a
1081+
// new one. Drop any pending retry reuse target for the same reason.
9981082
s.currentAssistantMsgId = undefined;
1083+
s.retryReuseMsgId = undefined;
9991084
break;
10001085
}
10011086

@@ -1366,6 +1451,7 @@ const KNOWN_AGENT_CORE_TYPES = new Set([
13661451
'agent.status.updated',
13671452
'prompt.submitted',
13681453
'prompt.completed',
1454+
'prompt.aborted',
13691455
'session.meta.updated',
13701456
'compaction.started',
13711457
'compaction.completed',

apps/kimi-web/src/api/daemon/client.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ import type {
1717
AppSessionCursor,
1818
AppSessionRuntimeStatus,
1919
AppSessionSnapshot,
20-
AppSessionStatus,
2120
AppTask,
2221
AppTaskStatus,
2322
AppTerminal,
@@ -52,7 +51,6 @@ import {
5251
toWireApprovalResponse,
5352
toWirePromptSubmission,
5453
toWireQuestionResponse,
55-
toWireSessionStatus,
5654
toAppWorkspace,
5755
wireEventSeq,
5856
wireEventSessionId,
@@ -347,7 +345,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
347345

348346
async listSessions(
349347
input?: PageRequest & {
350-
status?: AppSessionStatus;
348+
busy?: boolean;
351349
workspaceId?: string;
352350
includeArchive?: boolean;
353351
archivedOnly?: boolean;
@@ -358,7 +356,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
358356
before_id: input?.beforeId,
359357
after_id: input?.afterId,
360358
page_size: input?.pageSize,
361-
status: input?.status ? toWireSessionStatus(input.status) : undefined,
359+
busy: input?.busy,
362360
include_archive: input?.includeArchive,
363361
archived_only: input?.archivedOnly,
364362
exclude_empty: input?.excludeEmpty,
@@ -565,7 +563,7 @@ export class DaemonKimiWebApi implements KimiWebApi {
565563
};
566564
traceKeyEvent('session:snapshot:accepted', {
567565
sessionId,
568-
status: snapshot.session.status,
566+
busy: snapshot.session.busy,
569567
seq: snapshot.asOfSeq,
570568
messageCount: snapshot.messages.length,
571569
durationMs: Date.now() - startedAt,

apps/kimi-web/src/api/daemon/eventReducer.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,11 @@ export interface KimiClientState {
6868
* live event won the race even when the goal entry stayed absent. */
6969
goalVersionBySession: Record<string, number>;
7070
lastSeqBySession: Record<string, number>;
71+
/** MAIN-agent turn in flight, per session — set from the main agent's
72+
* turn.started/turn.ended boundary events and seeded from the snapshot's
73+
* (main-only) inFlightTurn. Half of the working moon; subagent turns never
74+
* reach the events that set this. */
75+
turnActiveBySession: Record<string, boolean>;
7176
compactionBySession: Record<string, CompactionStatus>;
7277
config?: AppConfig | null;
7378
warnings: AppWarning[];
@@ -85,6 +90,7 @@ export function createInitialState(): KimiClientState {
8590
goalBySession: {},
8691
goalVersionBySession: {},
8792
lastSeqBySession: {},
93+
turnActiveBySession: {},
8894
compactionBySession: {},
8995
warnings: [],
9096
};
@@ -112,6 +118,7 @@ function cloneState(s: KimiClientState): KimiClientState {
112118
goalBySession: { ...s.goalBySession },
113119
goalVersionBySession: { ...s.goalVersionBySession },
114120
lastSeqBySession: { ...s.lastSeqBySession },
121+
turnActiveBySession: { ...s.turnActiveBySession },
115122
compactionBySession: { ...s.compactionBySession },
116123
warnings: [...s.warnings],
117124
};
@@ -342,22 +349,33 @@ export function reduceAppEvent(
342349
delete next.approvalsBySession[id];
343350
delete next.questionsBySession[id];
344351
delete next.lastSeqBySession[id];
352+
delete next.turnActiveBySession[id];
345353
if (next.activeSessionId === id) {
346354
next.activeSessionId = undefined;
347355
}
348356
break;
349357
}
350358

351359
// -------------------------------------------------------------------------
352-
case 'sessionStatusChanged': {
360+
case 'sessionWorkChanged': {
353361
next.sessions = next.sessions.map((s) => {
354362
if (s.id !== event.sessionId) return s;
355363
return {
356364
...s,
357-
status: event.status,
358-
currentPromptId: event.currentPromptId,
365+
busy: event.busy,
366+
mainTurnActive: event.mainTurnActive ?? (event.busy ? s.mainTurnActive : false),
367+
pendingInteraction: event.pendingInteraction ?? s.pendingInteraction,
368+
// Authoritative, not nullish-merge: an omitted last_turn_reason is
369+
// how the server says "no current outcome" (a fresh turn cleared
370+
// the previous one), so the stale value must not survive.
371+
lastTurnReason: event.lastTurnReason,
359372
};
360373
});
374+
if (event.mainTurnActive === true) {
375+
next.turnActiveBySession[event.sessionId] = true;
376+
} else if (event.mainTurnActive === false || !event.busy) {
377+
delete next.turnActiveBySession[event.sessionId];
378+
}
361379
break;
362380
}
363381

@@ -725,6 +743,29 @@ export function reduceAppEvent(
725743
case 'agentTurnEnded':
726744
break;
727745

746+
// -------------------------------------------------------------------------
747+
// Prompt-level lifecycle events drive the web layer's in-flight cleanup
748+
// (see useKimiWebClient.processEvent), not reducer state. Advance seq
749+
// silently.
750+
case 'promptCompleted':
751+
case 'promptAborted':
752+
break;
753+
754+
// -------------------------------------------------------------------------
755+
case 'turnActiveChanged': {
756+
next.sessions = next.sessions.map((session) =>
757+
session.id === event.sessionId
758+
? { ...session, mainTurnActive: event.active }
759+
: session,
760+
);
761+
if (event.active) {
762+
next.turnActiveBySession[event.sessionId] = true;
763+
} else {
764+
delete next.turnActiveBySession[event.sessionId];
765+
}
766+
break;
767+
}
768+
728769
case 'unknown': {
729770
// Distinguish no-op known events (sentinel _noop) from agent errors/warnings
730771
// and truly unknown events.

0 commit comments

Comments
 (0)