From 0cef160c4b900a3d78212cd5da4b80d335ea0b6f Mon Sep 17 00:00:00 2001 From: Luyu Cheng <2239547+chengluyu@users.noreply.github.com> Date: Mon, 27 Jul 2026 00:30:33 +0800 Subject: [PATCH 01/11] fix(agent-core): continue goal pursuit when a goal turn hits the per-turn step limit (#2210) * fix(agent-core): continue goal pursuit when a goal turn hits the per-turn step limit * chore(agent-core-v2): regenerate state manifest * fix(agent-core): start goal pursuit when the goal-creating turn hits the step limit * refactor(agent-core-v2): drop step-cap narration from goal module and test --- .changeset/goal-max-steps-continue.md | 5 + .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../src/agent/goal/goalService.ts | 34 ++++- .../test/agent/goal/goal.test.ts | 29 +++- packages/agent-core/src/agent/turn/index.ts | 59 +++++++- .../test/harness/goal-session.test.ts | 135 ++++++++++++++++++ 6 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 .changeset/goal-max-steps-continue.md diff --git a/.changeset/goal-max-steps-continue.md b/.changeset/goal-max-steps-continue.md new file mode 100644 index 0000000000..ff33430f99 --- /dev/null +++ b/.changeset/goal-max-steps-continue.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix goal pursuit being interrupted when a goal turn reaches the per-turn step limit (`loop_control.max_steps_per_turn`); the limit now splits goal work into more continuation turns instead of pausing the goal. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 128a9878d7..e26afd4492 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1002,7 +1002,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map): boolean { + return ( + result.reason === 'failed' && + normalizeGoalErrorPayload(result.error).code === LoopErrors.codes.LOOP_MAX_STEPS_EXCEEDED + ); +} + function goalFailurePauseReason(error: unknown): string { const payload = normalizeGoalErrorPayload(error); switch (payload.code) { diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 9827a91c7f..8c59fd7931 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -16,7 +16,14 @@ import { IGoalDeadlineScheduler } from '#/agent/goal/goalDeadlineScheduler'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { UpdateGoalToolInputSchema } from '#/agent/tools/goal/update-goal/update-goal'; import { UpdateGoalTool } from '#/agent/tools/goal/update-goal/updateGoalTool'; -import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop'; +import { + createMaxStepsExceededError, + IAgentLoopService, + type AfterStepContext, + type EnqueueReceipt, + type Step, + type Turn, +} from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentSwarmService } from '#/agent/swarm/swarm'; @@ -1545,6 +1552,26 @@ describe('AgentGoalService core workflow hooks', () => { expect(loopService.launches).toEqual([]); }); + it('continues the goal when a goal turn hits the per-turn step limit', async () => { + await goals.createGoal({ objective: 'finish the task' }); + + const turn = makeTurn(4); + eventBus.publish({ type: 'turn.started', turnId: turn.id, origin: USER_PROMPT_ORIGIN }); + await runGoalStep(loopService, turn); + endTurn(eventBus, turn, { reason: 'failed', error: createMaxStepsExceededError(1) }); + + expect(goals.getGoal().goal).toMatchObject({ status: 'active', turnsUsed: 1 }); + expect(loopService.launches).toHaveLength(1); + expect(loopService.drainNextBatch(context)).toBeDefined(); + expect(context.get().at(-1)?.origin).toEqual({ + kind: 'system_trigger', + name: 'goal_continuation', + }); + const prompt = JSON.stringify(context.get().at(-1)?.content); + expect(prompt).toContain('per-turn step limit'); + expect(prompt).toContain('Pick up where that turn stopped'); + }); + it('blocks active goals when the user prompt hook blocks the turn', async () => { await goals.createGoal({ objective: 'finish the task' }); diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 0e112594eb..11e20b054b 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -121,6 +121,20 @@ const GOAL_CONTINUATION_PROMPT = [ 'leaving the goal active. Do not ask the user for input unless a real blocker prevents progress.', ].join(' '); +/** + * Variant of {@link GOAL_CONTINUATION_PROMPT} used when the previous goal turn + * ended by hitting the per-turn step limit (`loop_control.max_steps_per_turn`). + * The limit fragments goal work into more continuation turns instead of + * pausing the goal; the notice tells the model why, so it can size the next + * slice to fit the limit. + */ +const GOAL_STEP_CAP_CONTINUATION_PROMPT = [ + 'The previous goal turn reached the per-turn step limit before finishing its work,', + 'so a new turn was started for you. Pick up where that turn stopped and keep each', + 'slice of work small enough to fit the limit.', + GOAL_CONTINUATION_PROMPT, +].join(' '); + export class TurnFlow { private steerBuffer: BufferedSteer[] = []; private turnId = -1; @@ -404,11 +418,15 @@ export class TurnFlow { // instead of stopping after the turn that merely started it. (The // already-active case took the early return above.) const goalBecameActive = this.agent.goal.getGoal().goal?.status === 'active'; + // The same per-turn-step-limit exemption as the driver's continuation + // loop: a turn that failed only at the step cap does not block the + // handoff — pursuit starts with a fresh continuation turn (told why). + const hitStepCap = isMaxStepsTurnFailure(end); if ( goalBecameActive && end.event.reason !== 'cancelled' && - end.event.reason !== 'failed' && - end.event.reason !== 'blocked' + end.event.reason !== 'blocked' && + (end.event.reason !== 'failed' || hitStepCap) ) { // The ordinary turn created or resumed the goal, so it counts as the // first active goal turn before the continuation driver takes over. @@ -419,7 +437,12 @@ export class TurnFlow { } return await this.driveGoal( this.allocateTurnId(), - [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }], + [ + { + type: 'text', + text: hitStepCap ? GOAL_STEP_CAP_CONTINUATION_PROMPT : GOAL_CONTINUATION_PROMPT, + }, + ], GOAL_CONTINUATION_ORIGIN, signal, ); @@ -438,7 +461,9 @@ export class TurnFlow { * full turn, then reads the goal status the model set via `UpdateGoal`: * `complete` (the record is cleared) / `blocked` stop the loop; `active` * (the model didn't decide) re-injects the goal reminder and runs the - * next continuation turn. Aborted or failed turns pause the goal. Goal-state + * next continuation turn. Aborted or failed turns pause the goal — except a + * turn that only failed by reaching the per-turn step limit, which just + * fragments goal work into more continuation turns. Goal-state * blockers, such as explicit `UpdateGoal('blocked')`, prompt-hook blocks, and * budget limits, block it (all resumable). Returns the final turn's result. */ @@ -470,7 +495,12 @@ export class TurnFlow { await this.agent.goal.pauseOnInterrupt({ reason: 'Paused after interruption' }); return end; } - if (end.event.reason === 'failed') { + // A turn that failed only by reaching the per-turn step limit ended at a + // clean step boundary, so it is not a goal failure: fall through to the + // normal continuation decision below and keep pursuing the goal. The + // `turn.ended` event still reports the failure (and the limit) to hosts. + const hitStepCap = isMaxStepsTurnFailure(end); + if (end.event.reason === 'failed' && !hitStepCap) { await this.agent.goal.pauseActiveGoal({ reason: goalFailurePauseReason(end.event.error) }); return end; } @@ -495,7 +525,12 @@ export class TurnFlow { } turnId = this.allocateTurnId(); - turnInput = [{ type: 'text', text: GOAL_CONTINUATION_PROMPT }]; + turnInput = [ + { + type: 'text', + text: hitStepCap ? GOAL_STEP_CAP_CONTINUATION_PROMPT : GOAL_CONTINUATION_PROMPT, + }, + ]; turnOrigin = GOAL_CONTINUATION_ORIGIN; } } @@ -1269,6 +1304,18 @@ function hasStepBudgetRemaining(maxSteps: number | undefined, currentStep: numbe return maxSteps === undefined || maxSteps <= 0 || currentStep < maxSteps; } +/** + * True when a turn ended `failed` only because it reached the per-turn step + * limit (`loop_control.max_steps_per_turn`). Such a turn stopped at a clean + * step boundary, so goal pursuit continues instead of pausing. + */ +function isMaxStepsTurnFailure(end: TurnEndResult): boolean { + return ( + end.event.reason === 'failed' && + end.event.error?.code === ErrorCodes.LOOP_MAX_STEPS_EXCEEDED + ); +} + function isTerminalUpdateGoalResult( toolName: string, args: unknown, diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 5b27ea829e..68be35ec42 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -475,6 +475,141 @@ describe('goal session end-to-end', () => { expect(agent.context.history.at(-1)?.role).toBe('tool'); }); + it('continues the goal when a goal turn hits maxStepsPerTurn', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession( + sessionDir, + events, + ['GetGoal', 'UpdateGoal'], + undefined, + undefined, + { providers: {}, loopControl: { maxStepsPerTurn: 1 } }, + ); + const api = new SessionAPIImpl(session); + await api.createGoal({ agentId: 'main', objective: 'work' }); + + // Each of the first two turns spends its single allowed step on a tool + // call and is then cut by the step cap; the goal must keep going. The + // third turn completes the goal within the cap. + scripted.mockNextResponse({ + type: 'function', + id: 'check-1', + name: 'GetGoal', + arguments: '{}', + }); + scripted.mockNextResponse({ + type: 'function', + id: 'check-2', + name: 'GetGoal', + arguments: '{}', + }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(3); + const maxStepEnds = events.filter( + (event) => + event['type'] === 'turn.ended' && + event['reason'] === 'failed' && + (event['error'] as Record | undefined)?.['code'] === + ErrorCodes.LOOP_MAX_STEPS_EXCEEDED, + ); + expect(maxStepEnds).toHaveLength(2); + // The cap never pauses or blocks the goal: no stopped status is ever + // emitted, and the goal completes (record cleared) in the third turn. + expect( + events.filter( + (event) => + event['type'] === 'goal.updated' && + ['paused', 'blocked'].includes( + String((event['snapshot'] as Record | null)?.['status']), + ), + ), + ).toEqual([]); + expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); + const completion = events.find( + (event) => + event['type'] === 'goal.updated' && + (event['change'] as Record | undefined)?.['kind'] === 'completion', + ); + expect((completion?.['change'] as Record)?.['stats']).toMatchObject({ + turnsUsed: 3, + }); + // Capped continuation turns are told why a new turn was started. + const history = JSON.stringify(agent.context.history); + expect(history).toContain('per-turn step limit'); + expect(history).toContain('Pick up where that turn stopped'); + }); + + it('starts pursuing a goal created mid-turn even when that turn hits maxStepsPerTurn', async () => { + const sessionDir = await makeTempDir(); + const events: Array> = []; + const { session, agent, scripted } = await setupSession( + sessionDir, + events, + ['CreateGoal', 'UpdateGoal'], + undefined, + undefined, + { providers: {}, loopControl: { maxStepsPerTurn: 1 } }, + ); + const api = new SessionAPIImpl(session); + + // No goal at turn start: the model creates one on the only allowed step, + // the turn is then cut by the cap, and pursuit must still begin — with a + // continuation turn that explains the cap. + scripted.mockNextResponse({ + type: 'function', + id: 'create', + name: 'CreateGoal', + arguments: JSON.stringify({ objective: 'work' }), + }); + scripted.mockNextResponse({ + type: 'function', + id: 'complete', + name: 'UpdateGoal', + arguments: JSON.stringify({ status: 'complete' }), + }); + + agent.turn.prompt([{ type: 'text', text: 'work' }]); + await agent.turn.waitForCurrentTurn(); + + expect(scripted.calls).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + reason: 'failed', + error: expect.objectContaining({ code: ErrorCodes.LOOP_MAX_STEPS_EXCEEDED }), + }), + ); + expect( + events.filter( + (event) => + event['type'] === 'goal.updated' && + ['paused', 'blocked'].includes( + String((event['snapshot'] as Record | null)?.['status']), + ), + ), + ).toEqual([]); + expect((await api.getGoal({ agentId: 'main' })).goal).toBeNull(); + const completion = events.find( + (event) => + event['type'] === 'goal.updated' && + (event['change'] as Record | undefined)?.['kind'] === 'completion', + ); + expect((completion?.['change'] as Record)?.['stats']).toMatchObject({ + turnsUsed: 2, + }); + expect(JSON.stringify(agent.context.history)).toContain('per-turn step limit'); + }); + it('pauses the goal on provider rate limits', async () => { const sessionDir = await makeTempDir(); const events: Array> = []; From cc9b25e132586176377b6b5a483a80583e0a6c9e Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 27 Jul 2026 10:58:38 +0800 Subject: [PATCH 02/11] fix(kap-server): flush public and control WS frames immediately (#2221) Only subscription events now enter the timed coalescing buffer. server_hello, acks, resync_required, and global session events join the outbound FIFO and flush it right away, so they no longer wait behind the flush window and cannot overtake earlier buffered subscription frames. The broadcaster marks global fan-out with the new BroadcastDelivery 'immediate' lane. --- .../ws/v1/sessionEventBroadcaster.ts | 7 +- .../src/transport/ws/v1/wsConnectionV1.ts | 60 ++++++++----- .../test/sessionEventBroadcaster.test.ts | 33 ++++++- .../kap-server/test/wsConnectionV1.test.ts | 90 ++++++++++++------- 4 files changed, 131 insertions(+), 59 deletions(-) diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 9a2b73a298..2d968c8e00 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -122,9 +122,12 @@ export interface SessionSnapshotState { subagents: SnapshotSubagent[]; } +/** Internal transport lane: only subscription traffic enters the timed buffer. */ +export type BroadcastDelivery = 'subscription' | 'immediate'; + /** A connection (or test double) that receives sequenced envelopes. */ export interface BroadcastTarget { - send(envelope: EventEnvelope): void; + send(envelope: EventEnvelope, delivery?: BroadcastDelivery): void; } /** @@ -1208,7 +1211,7 @@ export class SessionEventBroadcaster { for (const target of this.allTargets()) recipients.add(target); for (const target of recipients) { try { - target.send(envelope); + target.send(envelope, 'immediate'); } catch { // best-effort fan-out; a broken target is dropped, not fatal } diff --git a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts index cf1745720a..555c37fe6a 100644 --- a/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts +++ b/packages/kap-server/src/transport/ws/v1/wsConnectionV1.ts @@ -44,6 +44,7 @@ import { } from './protocol'; import { type AgentFilter, + type BroadcastDelivery, type BroadcastTarget, type ResyncReason, type SessionEventBroadcaster, @@ -56,9 +57,10 @@ const DEFAULT_MAX_BUFFER_SIZE = 1000; /** Per-session subscription state held by the connection (see `TargetSubscription`). */ type SessionSubscription = TargetSubscription; -// Outbound send buffer — coalesces a burst of frames (notably high-frequency -// volatile text deltas) into fewer `socket.send` calls and applies backpressure -// when the peer is not draining fast enough. See `flush()` / `coalesceFrames`. +// Subscription-event send buffer — coalesces a burst of frames (notably +// high-frequency volatile text deltas) within one render-frame-sized window. +// Public/control frames are immediate barriers: they enter the same FIFO and +// flush any earlier subscription frames so cross-channel order stays intact. const DEFAULT_FLUSH_INTERVAL_MS = 16; const DEFAULT_MAX_BATCH_SIZE = 64; const DEFAULT_HIGH_WATER_MARK_BYTES = 1 << 20; // 1 MiB @@ -88,9 +90,9 @@ export interface WsConnectionV1Options { readonly userAgent: string | null; readonly logger?: JournalLogger; readonly maxBufferSize?: number; - /** Delay before a buffered batch is flushed; coalesces frames within the window. */ + /** Delay before buffered subscription events are flushed. */ readonly flushIntervalMs?: number; - /** Flush immediately once this many frames are queued, even before the interval. */ + /** Flush subscription events once this many frames are queued. */ readonly maxBatchSize?: number; /** `socket.bufferedAmount` above which flushing is deferred (backpressure). */ readonly highWaterMarkBytes?: number; @@ -156,7 +158,7 @@ export class WsConnectionV1 implements BroadcastTarget { // connection without any subscription; session/agent events stay // subscribe-gated via `broadcaster.subscribe`. this.broadcaster.addGlobalTarget(this); - this.sendFrame( + this.sendImmediateFrame( buildServerHello({ ws_connection_id: this.id, protocol_version: WS_PROTOCOL_VERSION, @@ -174,9 +176,10 @@ export class WsConnectionV1 implements BroadcastTarget { return Array.from(this.subscriptions.keys()).sort(); } - /** BroadcastTarget — forward a sequenced envelope to the socket. */ - send(envelope: EventEnvelope): void { - this.sendFrame(envelope); + /** BroadcastTarget — buffer subscription traffic; public traffic is a FIFO barrier. */ + send(envelope: EventEnvelope, delivery: BroadcastDelivery = 'subscription'): void { + if (delivery === 'immediate') this.sendImmediateFrame(envelope); + else this.sendSubscribedFrame(envelope); } private onMessage(data: RawData): void { @@ -253,7 +256,7 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 0, 'success', { accepted_subscriptions: accepted, resync_required: resyncRequired, @@ -286,7 +289,7 @@ export class WsConnectionV1 implements BroadcastTarget { ); } - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 0, 'success', { accepted, not_found: notFound, @@ -307,7 +310,7 @@ export class WsConnectionV1 implements BroadcastTarget { private async onSubscribeV2(frame: InboundFrame): Promise { const parsed = transcriptSubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); if (!parsed.success) { - this.sendFrame(buildAck(frame.id ?? '', 1, 'invalid subscribe_v2 payload', {})); + this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid subscribe_v2 payload', {})); return; } const sid = parsed.data.session_id; @@ -326,7 +329,7 @@ export class WsConnectionV1 implements BroadcastTarget { { accepted, resyncRequired, serverCursors, notFound }, ); - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 0, 'success', { accepted, not_found: notFound, @@ -347,7 +350,7 @@ export class WsConnectionV1 implements BroadcastTarget { private async onUnsubscribeV2(frame: InboundFrame): Promise { const parsed = unsubscribeV2PayloadSchema.safeParse(frame.payload ?? {}); if (!parsed.success) { - this.sendFrame(buildAck(frame.id ?? '', 1, 'invalid unsubscribe_v2 payload', {})); + this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'invalid unsubscribe_v2 payload', {})); return; } const sid = parsed.data.session_id; @@ -363,7 +366,7 @@ export class WsConnectionV1 implements BroadcastTarget { }); } - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 0, 'success', { accepted: [sid], not_found: [], @@ -379,7 +382,7 @@ export class WsConnectionV1 implements BroadcastTarget { this.broadcaster.unsubscribe(sid, this); this.subscriptions.delete(sid); } - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 0, 'success', { accepted: [], not_found: [], @@ -394,7 +397,7 @@ export class WsConnectionV1 implements BroadcastTarget { const paths = asStringArray(payload['paths']); const bridge = this.fsWatchBridge; if (bridge === undefined) { - this.sendFrame(buildAck(frame.id ?? '', 1, 'fs watch unavailable', {})); + this.sendImmediateFrame(buildAck(frame.id ?? '', 1, 'fs watch unavailable', {})); return; } let result; @@ -403,14 +406,14 @@ export class WsConnectionV1 implements BroadcastTarget { ? await bridge.addWatch(this, sessionId, paths) : await bridge.removeWatch(this, sessionId, paths); } catch (error) { - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', 1, 'internal error', { message: error instanceof Error ? error.message : String(error), }), ); return; } - this.sendFrame( + this.sendImmediateFrame( buildAck(frame.id ?? '', result.code, result.msg, { watched_paths: result.watched_paths ?? [], current_count: result.current_count ?? 0, @@ -472,12 +475,12 @@ export class WsConnectionV1 implements BroadcastTarget { ): Promise { const result = await this.broadcaster.getBufferedSince(sid, cursor, filter, transcriptGrades); if (result.resyncRequired !== false) { - this.sendFrame( + this.sendImmediateFrame( buildResyncRequired(sid, result.resyncRequired as ResyncReason, result.currentSeq, result.epoch), ); resyncRequired.push(sid); } else { - for (const { envelope } of result.events) this.sendFrame(envelope); + for (const { envelope } of result.events) this.sendSubscribedFrame(envelope); } serverCursors[sid] = { seq: result.currentSeq, epoch: result.epoch }; } @@ -497,14 +500,15 @@ export class WsConnectionV1 implements BroadcastTarget { ok = false; } if (!ok) { - this.sendFrame(buildAck(frame.id ?? '', 40112, 'unauthorized', {})); + this.sendImmediateFrame(buildAck(frame.id ?? '', 40112, 'unauthorized', {})); this.close(); return false; } return true; } - private sendFrame(msg: unknown): void { + /** Queue an event delivered through `subscribe` / `subscribe_v2`. */ + private sendSubscribedFrame(msg: unknown): void { if (this.closed) return; this.outbound.push(msg); if (this.outbound.length >= this.maxBatchSize) { @@ -515,6 +519,16 @@ export class WsConnectionV1 implements BroadcastTarget { this.scheduleFlush(); } + /** + * Public/control frames do not start a timer. They join the FIFO and flush it + * immediately, so no later frame can overtake earlier subscription traffic. + */ + private sendImmediateFrame(msg: unknown): void { + if (this.closed) return; + this.outbound.push(msg); + this.flush(); + } + private scheduleFlush(): void { if (this.flushTimer !== undefined) return; this.flushTimer = setTimeout(() => { diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 45f10d4360..71c900f2cf 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -37,6 +37,7 @@ import type { AgentEvent } from '../src/transport/ws/v1/events'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + type BroadcastDelivery, type BroadcastTarget, SessionEventBroadcaster, } from '../src/transport/ws/v1/sessionEventBroadcaster'; @@ -382,9 +383,23 @@ function agentEvent(type: string, extra: Record = {}): AgentEve return { type, ...extra } as unknown as AgentEvent; } -function collectingTarget(): { target: BroadcastTarget; envelopes: EventEnvelope[] } { +function collectingTarget(): { + target: BroadcastTarget; + envelopes: EventEnvelope[]; + deliveries: BroadcastDelivery[]; +} { const envelopes: EventEnvelope[] = []; - return { target: { send: (e) => envelopes.push(e) }, envelopes }; + const deliveries: BroadcastDelivery[] = []; + return { + target: { + send: (envelope, delivery = 'subscription') => { + envelopes.push(envelope); + deliveries.push(delivery); + }, + }, + envelopes, + deliveries, + }; } // A real turn yields the event loop between `turn.started` and `turn.ended`, @@ -425,7 +440,7 @@ describe('SessionEventBroadcaster', () => { const main = lc.addAgent('main'); sessions.set('s1', lc); - const { target, envelopes } = collectingTarget(); + const { target, envelopes, deliveries } = collectingTarget(); expect(await bc.subscribe('s1', target)).toBe(true); main.bus.emit(agentEvent('turn.started', { turnId: 1 })); @@ -451,6 +466,16 @@ describe('SessionEventBroadcaster', () => { }); expect(envelopes.every((e) => e.epoch === envelopes[0]!.epoch)).toBe(true); expect(durable[1]!.volatile).toBeUndefined(); + expect( + envelopes.flatMap((envelope, index) => + envelope.volatile === true ? [] : [[envelope.type, deliveries[index]]], + ), + ).toEqual([ + ['turn.started', 'subscription'], + ['event.session.work_changed', 'immediate'], + ['turn.ended', 'subscription'], + ['event.session.work_changed', 'immediate'], + ]); }); it('fans out volatile events with the current watermark + offset, not journaled', async () => { @@ -895,6 +920,7 @@ describe('SessionEventBroadcaster', () => { type: 'event.session.created', session_id: 's1', }); + expect(globalView.deliveries).toEqual(['immediate']); }); it('delivers work_changed to a global-only target while a subscriber drives the session', async () => { @@ -1701,6 +1727,7 @@ describe('SessionEventBroadcaster', () => { session_id: 's1', payload: { agent_id: 'main', has_more_older: false, snapshot: { items: [] } }, }); + expect(view.deliveries).toEqual(['subscription']); } expect(transcriptEnvelopes(plainView.envelopes)).toHaveLength(0); diff --git a/packages/kap-server/test/wsConnectionV1.test.ts b/packages/kap-server/test/wsConnectionV1.test.ts index 7c3fa6af9d..8012963670 100644 --- a/packages/kap-server/test/wsConnectionV1.test.ts +++ b/packages/kap-server/test/wsConnectionV1.test.ts @@ -636,25 +636,40 @@ describe('WsConnectionV1 outbound buffer', () => { vi.useRealTimers(); }); - it('buffers server_hello and flushes it after the interval', async () => { + it('sends server_hello immediately', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16 }); + expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual(['server_hello']); + conn.close(); + }); + + it('buffers subscribe_v2 transcript frames without merging them', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + conn.send(durable('transcript.reset', 's1', 7)); + conn.send(durable('transcript.ops', 's1', 8)); expect(socket.sent).toHaveLength(0); - await vi.advanceTimersByTimeAsync(16); - expect(socket.frames().map((f) => (f as { type: string }).type)).toContain('server_hello'); + await vi.advanceTimersByTimeAsync(15); + expect(socket.sent).toHaveLength(0); + await vi.advanceTimersByTimeAsync(1); + + const frames = socket.frames() as Array<{ type: string; seq: number }>; + expect(frames.map((frame) => frame.type)).toEqual(['transcript.reset', 'transcript.ops']); + expect(frames.map((frame) => frame.seq)).toEqual([7, 8]); conn.close(); }); - it('coalesces adjacent deltas into one socket.send', async () => { + it('coalesces adjacent subscribed deltas into one socket.send', async () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16 }); - await vi.advanceTimersByTimeAsync(16); // flush server_hello socket.sent = []; conn.send(delta('s1', 'main', 1, 'Hello', 0)); conn.send(delta('s1', 'main', 1, ' ', 5)); conn.send(delta('s1', 'main', 1, 'world', 6)); - expect(socket.sent).toHaveLength(0); // still buffered + expect(socket.sent).toHaveLength(0); await vi.advanceTimersByTimeAsync(16); const frames = socket.frames(); @@ -666,15 +681,36 @@ describe('WsConnectionV1 outbound buffer', () => { conn.close(); }); - it('flushes immediately once the batch reaches maxBatchSize', async () => { + it('sends public events immediately and preserves FIFO with subscribed events', async () => { + const socket = new FakeSocket(); + const conn = makeConn(socket, { flushIntervalMs: 16 }); + socket.sent = []; + + conn.send(delta('s1', 'main', 1, 'before', 0)); + expect(socket.sent).toHaveLength(0); + conn.send(durable('event.session.work_changed', 's1', 2), 'immediate'); + + expect(socket.frames().map((f) => (f as { type: string }).type)).toEqual([ + 'assistant.delta', + 'event.session.work_changed', + ]); + await vi.advanceTimersByTimeAsync(16); + expect(socket.sent).toHaveLength(2); + conn.close(); + }); + + it('flushes immediately once the subscribed batch reaches maxBatchSize', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 1000, maxBatchSize: 3 }); - // constructor already queued server_hello (1); two deltas bring it to 3. + socket.sent = []; + conn.send(delta('s1', 'main', 1, 'a', 0)); conn.send(delta('s1', 'main', 1, 'b', 1)); - // No timer advanced — flush must have happened synchronously. - const types = socket.frames().map((f) => (f as { type: string }).type); - expect(types).toEqual(['server_hello', 'assistant.delta']); + conn.send(delta('s1', 'main', 1, 'c', 2)); + + const frames = socket.frames(); + expect(frames).toHaveLength(1); + expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('abc'); conn.close(); }); @@ -684,53 +720,45 @@ describe('WsConnectionV1 outbound buffer', () => { flushIntervalMs: 16, highWaterMarkBytes: 100, }); - await vi.advanceTimersByTimeAsync(16); // flush server_hello socket.sent = []; - socket.bufferedAmount = 200; // above the watermark + socket.bufferedAmount = 200; conn.send(delta('s1', 'main', 1, 'Hello', 0)); - await vi.advanceTimersByTimeAsync(16); // flush attempted → deferred + await vi.advanceTimersByTimeAsync(16); expect(socket.sent).toHaveLength(0); - // More deltas arrive while deferred — they merge into the queued frame. conn.send(delta('s1', 'main', 1, ' world', 5)); - await vi.advanceTimersByTimeAsync(5); // backpressure retry, still high + await vi.advanceTimersByTimeAsync(5); expect(socket.sent).toHaveLength(0); - socket.bufferedAmount = 0; // peer drained - await vi.advanceTimersByTimeAsync(5); // retry succeeds + socket.bufferedAmount = 0; + await vi.advanceTimersByTimeAsync(5); const frames = socket.frames(); expect(frames).toHaveLength(1); expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('Hello world'); conn.close(); }); - it('force-flushes buffered frames on close', async () => { + it('force-flushes buffered subscription frames on close', () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 1000 }); - // server_hello is still buffered (interval not elapsed). + socket.sent = []; + conn.send(delta('s1', 'main', 1, 'tail', 0)); expect(socket.sent).toHaveLength(0); conn.close(); - const types = socket.frames().map((f) => (f as { type: string }).type); - expect(types).toContain('server_hello'); - expect(types).toContain('assistant.delta'); - const tail = socket - .frames() - .find((f) => (f as { type: string }).type === 'assistant.delta') as { - payload: { delta: string }; - }; - expect(tail.payload.delta).toBe('tail'); + const frames = socket.frames(); + expect(frames).toHaveLength(1); + expect((frames[0] as { payload: { delta: string } }).payload.delta).toBe('tail'); }); it('drops buffered frames when the socket is already closed at flush time', async () => { const socket = new FakeSocket(); const conn = makeConn(socket, { flushIntervalMs: 16 }); - await vi.advanceTimersByTimeAsync(16); // flush server_hello socket.sent = []; - socket.readyState = socket.CLOSED; // peer went away + socket.readyState = socket.CLOSED; conn.send(delta('s1', 'main', 1, 'lost', 0)); await vi.advanceTimersByTimeAsync(16); expect(socket.sent).toHaveLength(0); From d40d0d305d2866cb5ab8696e559e0813b5f92201 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 11:09:26 +0800 Subject: [PATCH 03/11] refactor(agent-core-v2): make undo domain-owned (#2055) * refactor(agent-core-v2): rebuild undo as wire-level journal rewind Replace the compensating context.undo op with a wire-layer rewind primitive: a log.cut control record with a persisted target, applied uniformly by the wire during fold. Turn boundaries become first-class (TurnIndexModel indexing turn.prompt record positions), models declare a temporal classification (rewindable), and a single IAgentRewindService owns the undo pipeline (quiesce -> precheck -> cut -> reconcile) with all entry points converged. - wire: log.cut record, rewindable model flag, re-fold rebuild; OpApplyContext.recordIndex for position-aware reducers - rewind service: aborts the active turn, cancels in-flight compaction, preserves the pending queue, rebases measured tokens, reconciles lastPrompt, tracks conversation_undo - todo list, plan mode, task-notification delivery and the turn index now rewind together with the undone turns - transcript reducer applies cut ranges so snapshot/messages surfaces stay consistent with the model context - REST/RPC/debug undo entry points converge on the rewind service; TUI parses the v2 undo-unavailable error shape - legacy context.undo records keep replaying for old journals * refactor(agent-core-v2): keep undo domain-owned * refactor: enhance /undo functionality for consistency and safety, including todo list rollback and improved event handling * chore: clean up undo changeset artifacts * refactor: rebuild rewind consistency * fix: make conversation undo durable and consistent * fix(agent-core-v2): stabilize undo restoration * fix: keep TUI undo on legacy error contract * refactor(agent-core-v2): drop unused full compaction cancel API Undo now rejects with session.busy while compaction runs instead of cancelling it, so the awaitable cancel() added for the earlier rewind semantics has no callers left. Remove it from the interface and implementation; the RPC cancel path keeps using the task abort controller directly. * fix(agent-core-v2): remove injected context on undo * chore(agent-core-v2): regenerate wire manifest * docs(agent-core-dev): rename rewind to undo in layer table * fix(agent-core-v2): undo prompt-owned image reminders * refactor: remove transcript undo reconciliation * fix(kap-server): map undo busy errors * refactor(agent-core-v2): rename undo participant registry and attribute checkpoint depth - Rename IAgentConversationUndoReconciliationRegistry to IAgentConversationUndoParticipantRegistry (conversationUndoParticipants). - Return the limiting model from checkpointDepth and include it in the SESSION_UNDO_UNAVAILABLE details; report checkpoint_lost instead of compaction_boundary when no compaction explains the missing depth. - Add a registry invariant test: every model reacting to context.* ops must be registered via defineCheckpointedModel or explicitly exempt. * Delete .changeset/fix-undo-injections.md Signed-off-by: 7Sageer --------- Signed-off-by: 7Sageer --- .agents/skills/agent-core-dev/orient.md | 2 +- .changeset/undo-rewind-consistency.md | 5 + .../kimi-web/src/components/chat/ChatPane.vue | 10 +- .../src/components/chat/ConversationPane.vue | 6 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- packages/agent-core-v2/AGENTS.md | 4 + .../agent-core-v2/docs/state-manifest.d.ts | 6 +- .../agent-core-v2/docs/wire-manifest.d.ts | 9 +- .../scripts/check-domain-layers.mjs | 5 + .../contextMemory/contextMemoryService.ts | 24 +- .../src/agent/contextMemory/contextOps.ts | 42 +- .../agent/contextMemory/contextTranscript.ts | 47 +- .../agent/contextMemory/conversationTime.ts | 85 +++ .../conversationUndoParticipants.ts | 62 +++ .../src/agent/contextMemory/types.ts | 1 + .../fullCompaction/fullCompactionService.ts | 3 +- packages/agent-core-v2/src/agent/loop/loop.ts | 2 + .../src/agent/loop/loopService.ts | 81 ++- .../agent-core-v2/src/agent/loop/turnOps.ts | 75 +-- .../agent-core-v2/src/agent/plan/planOps.ts | 43 +- .../src/agent/plan/planService.ts | 23 +- .../agent-core-v2/src/agent/prompt/prompt.ts | 1 - .../src/agent/prompt/promptMetadataText.ts | 66 +++ .../src/agent/prompt/promptService.ts | 19 +- .../src/agent/prompt/promptStepRequests.ts | 10 +- .../agent-core-v2/src/agent/rpc/core-api.ts | 2 +- .../src/agent/rpc/prompt-metadata.ts | 69 +-- .../agent-core-v2/src/agent/rpc/rpcService.ts | 9 +- .../src/agent/task/taskService.ts | 191 +++++-- .../src/agent/toolSelect/toolSelectService.ts | 13 +- packages/agent-core-v2/src/agent/undo/undo.ts | 23 + .../src/agent/undo/undoService.ts | 248 +++++++++ packages/agent-core-v2/src/index.ts | 4 + .../src/session/todo/sessionTodoService.ts | 46 +- .../agent-core-v2/src/session/todo/todoOps.ts | 31 +- .../test/agent/contextMemory/context.test.ts | 57 +- .../contextMemory/contextTranscript.test.ts | 19 + .../agent/contextMemory/undoPrecheck.test.ts | 105 ++-- .../test/agent/loop/loop.test.ts | 59 ++ .../agent-core-v2/test/agent/loop/stubs.ts | 1 + .../test/agent/plan/plan.test.ts | 4 +- .../test/agent/plan/planOps.test.ts | 46 +- .../test/agent/rpc/undoHistory.test.ts | 19 +- .../test/agent/skill/skill.test.ts | 2 - .../test/agent/task/rpc-events.test.ts | 106 ++++ .../test/agent/task/taskService.test.ts | 13 + .../agent/toolSelect/toolSelect.e2e.test.ts | 3 +- .../toolSelect/toolSelectService.test.ts | 4 + .../test/agent/undo/undo.test.ts | 509 ++++++++++++++++++ .../externalHooksRunner/integration.test.ts | 2 +- .../test/app/gateway/gateway.test.ts | 1 - packages/agent-core-v2/test/harness/agent.ts | 35 +- packages/agent-core-v2/test/index.test.ts | 39 +- .../test/session/todo/sessionTodo.test.ts | 35 +- packages/agent-core-v2/test/tool/tool.test.ts | 7 + .../agent-core-v2/test/wire/resume.test.ts | 82 +++ packages/kap-server/src/routes/sessions.ts | 16 +- packages/kap-server/test/sessions.test.ts | 38 +- 59 files changed, 2070 insertions(+), 403 deletions(-) create mode 100644 .changeset/undo-rewind-consistency.md create mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts create mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts create mode 100644 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts create mode 100644 packages/agent-core-v2/src/agent/undo/undo.ts create mode 100644 packages/agent-core-v2/src/agent/undo/undoService.ts create mode 100644 packages/agent-core-v2/test/agent/undo/undo.test.ts diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 402ff9304b..54f74f0083 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -64,7 +64,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but | L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | | L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | | L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | -| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal` | +| L6 | coordination | `session`, `agentLifecycle`, `sessionMetadata`, `interaction`, `terminal`, `undo` | | L7 | boundary / edge | `gateway`, `rpc`, `approval`, `question`, `*Legacy` | ## File-header comment convention diff --git a/.changeset/undo-rewind-consistency.md b/.changeset/undo-rewind-consistency.md new file mode 100644 index 0000000000..0a751fb58d --- /dev/null +++ b/.changeset/undo-rewind-consistency.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. diff --git a/apps/kimi-web/src/components/chat/ChatPane.vue b/apps/kimi-web/src/components/chat/ChatPane.vue index 5886e0ef90..79ad6c5d19 100644 --- a/apps/kimi-web/src/components/chat/ChatPane.vue +++ b/apps/kimi-web/src/components/chat/ChatPane.vue @@ -274,7 +274,7 @@ function onQueueDragEnd(): void { } // Id of the most recent user turn — the only one offered an "edit & resend" -// affordance (undo only rewinds the latest exchange). +// affordance (undo only removes the latest exchange). const lastUserTurnId = computed(() => { for (let i = props.turns.length - 1; i >= 0; i--) { if (props.turns[i]!.role === 'user') return props.turns[i]!.id; @@ -314,10 +314,10 @@ function compactionDividerLabel(turn: ChatTurn): string { // Per-turn copy button state (keyed by turn id) const copiedTurn = ref(null); -// Undo in-flight guard (keyed by turn id) — set while the server rewinds the +// Undo in-flight guard (keyed by turn id) — set while the server undoes the // turn so a second undo can't fire until the first one settles. const undoingTurnId = ref(null); -// Fallback that releases the undoing state if the server rewind never removes +// Fallback that releases the undoing state if the server undo never removes // the turn (e.g. the undo failed). Without it the guard in confirmEditMessage // would block any further undo. let undoFallbackTimer: ReturnType | null = null; @@ -339,7 +339,7 @@ function confirmEditMessage(turn: ChatTurn): void { if (undoingTurnId.value !== null) return; undoingTurnId.value = turn.id; emit('editMessage', { text: turn.text, attachments: turn.attachments }); - // Fallback: if the server rewind never removes the turn (e.g. it failed), + // Fallback: if the server undo never removes the turn (e.g. it failed), // release the guard so the user can retry. undoFallbackTimer = setTimeout(() => { undoFallbackTimer = null; @@ -347,7 +347,7 @@ function confirmEditMessage(turn: ChatTurn): void { }, UNDO_FALLBACK_MS); } -// Release the undoing guard once the server rewind has actually removed the turn +// Release the undoing guard once the server undo has actually removed the turn // from the list (post-render, so the element is already gone). watch( () => props.turns, diff --git a/apps/kimi-web/src/components/chat/ConversationPane.vue b/apps/kimi-web/src/components/chat/ConversationPane.vue index 1b6b1a9e79..0464beac9b 100644 --- a/apps/kimi-web/src/components/chat/ConversationPane.vue +++ b/apps/kimi-web/src/components/chat/ConversationPane.vue @@ -828,7 +828,7 @@ watch(scrollKey, async (next, prev) => { } await nextTick(); if (following.value || hasUserActionFollowLock()) { - // A rewind (undo / compaction) shortens the transcript — glide to the new + // An undo or compaction shortens the transcript — glide to the new // bottom smoothly; growth (new turns / streaming) snaps instantly so the // follow keeps up with the tail. scrollToBottom(next.length < prev.length); @@ -928,9 +928,9 @@ function handleComposerSubmit(payload: { text: string; attachments: PromptAttach emit('submit', payload); } -// Undo ("edit & resend") rewinds the transcript asynchronously — the server +// Undo ("edit & resend") shortens the transcript asynchronously — the server // round-trip in App.vue's handleEditMessage truncates the turns after this emit -// returns. Scrolling here would target the pre-rewind bottom and fight the +// returns. Scrolling here would target the pre-undo bottom and fight the // bubble-exit animation, so we only arm the follow state; the scrollKey watcher // smooth-scrolls once the truncated turns actually land. function handleEditMessage(payload: { diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 75c8033564..d3ab8bf4cb 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -32,7 +32,7 @@ Some commands are only available in the idle state. Executing these commands whi | `/fork` | — | Fork a new session from the current one, preserving the full conversation history | No | | `/title []` | `/rename` | Without arguments, display the current session title; with an argument, set a new title (max 200 characters) | Yes | | `/compact []` | — | Compact the current conversation context to free up token usage; an optional custom instruction can hint to the model what to preserve | No | -| `/undo []` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone | No | +| `/undo []` | — | Undo recent prompts from the active context. Without a count, opens a selector; with a count, undoes that many prompts. Prompts before the last compaction cannot be undone. Undoing also rolls back the todo list and plan mode state produced by those prompts (code changes are not reverted) | No | | `/reload` | — | Reload the current session and apply the latest `config.toml` settings (providers, models, etc.) and `tui.toml` UI preferences, without restarting the CLI | No | | `/reload-tui` | — | Reload only the `tui.toml` UI preferences (theme, editor, notifications, etc.) without rebuilding the session | Yes | | `/init` | — | Analyze the current codebase and generate `AGENTS.md` | No | diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index ec4d2b79dc..a1882cf87e 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -32,7 +32,7 @@ | `/fork` | — | 基于当前会话 fork 一份新会话,保留完整对话历史 | 否 | | `/title []` | `/rename` | 不带参数时显示当前会话标题;带参数时设置为新标题(最长 200 字符) | 是 | | `/compact []` | — | 压缩当前对话上下文,释放 token 占用;可附带自定义指令,提示模型压缩时保留哪些信息 | 否 | -| `/undo []` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销 | 否 | +| `/undo []` | — | 从当前上下文撤销最近的提示词。不带数量时打开选择器;带数量时撤销对应条数。最后一次上下文压缩之前的提示词不能撤销。撤销会一并回滚这些提示词产生的 todo 列表和计划模式状态(不回滚代码改动) | 否 | | `/init` | — | 分析当前代码库并生成 `AGENTS.md` | 否 | | `/export-md []` | `/export` | 将当前会话导出为 Markdown 文件 | 否 | | `/export-debug-zip` | — | 将当前会话导出为调试用 ZIP 压缩包(与 [`kimi export`](./kimi-command.md#kimi-export) 行为一致) | 否 | diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index b63c3df196..8646b066a2 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -53,6 +53,10 @@ Business domains **do not implement persistence themselves** — they depend on Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / atomic writes, or hold file handles. Generic Stores are named by **access pattern** (`IAppendLogStore`, `IAtomicDocumentStore`); only domain-unique Stores are named after the domain (`ISessionIndex`). See `.agents/skills/agent-core-dev/persistence.md` for the full layering rules and decision tree. +## Conversation undo + +`context.undo` is the only persisted undo fact. `contextMemory/conversationTime.ts` owns the conversation clock (`isUndoAnchor` — the single tick predicate used by `computeUndoCut`, the checkpoint reducers, and the transcript reducer) and the checkpoint protocol. A wire Model whose state must follow conversation undo (todo, plan, task-notification delivery, …) **MUST** be defined with `defineCheckpointedModel` — never hand-roll the push/clear/restore reducers — which also registers it into `CHECKPOINTED_MODELS` for the undo pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. + ## Docs Per-domain references live in `docs/`. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index e26afd4492..6c6e8ae80c 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -653,6 +653,7 @@ export interface SessionStateSnapshot { } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'injection'; readonly variant: string; + readonly ownerPromptId?: string; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -728,6 +729,7 @@ export interface AgentStateSnapshot { } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'injection'; readonly variant: string; + readonly ownerPromptId?: string; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -851,6 +853,7 @@ export interface AgentStateSnapshot { } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'injection'; readonly variant: string; + readonly ownerPromptId?: string; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -906,6 +909,7 @@ export interface AgentStateSnapshot { } | /* InjectionOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'injection'; readonly variant: string; + readonly ownerPromptId?: string; } | /* ShellCommandOrigin — packages/agent-core-v2/src/agent/contextMemory/types.ts */ { readonly kind: 'shell_command'; readonly phase: 'input' | 'output'; @@ -1002,7 +1006,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map 0 && + isPromptOwnedInjection(state[cutIndex - 1]!, message) + ) { + cutIndex--; + } } } return { cutIndex, removedCount, stoppedAtCompaction }; @@ -317,7 +333,11 @@ export function isFullyUndoable(cut: UndoCut, count: number): boolean { return cut.cutIndex >= 0 && cut.removedCount >= count; } -export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient'; +export type UndoUnavailableReason = + | 'empty' + | 'compaction_boundary' + | 'insufficient' + | 'checkpoint_lost'; export type UndoPrecheck = | { readonly ok: true } @@ -349,25 +369,19 @@ export function formatUndoUnavailableMessage( return 'Nothing to undo: would cross a compaction boundary'; case 'insufficient': return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available`; + case 'checkpoint_lost': + return 'Nothing to undo: conversation state checkpoints are incomplete'; } } export const contextUndo = ContextModel.defineOp('context.undo', { - schema: z.object({ count: z.number() }), + schema: z.object({ + count: z.number().int().positive().max(Number.MAX_SAFE_INTEGER), + }), apply: (state, p) => { - if (p.count <= 0 || state.length === 0) return state; + if (!isValidUndoCount(p.count) || state.length === 0) return state; const cut = computeUndoCut(state, p.count); if (!isFullyUndoable(cut, p.count)) return state; return resetFold(state.slice(0, cut.cutIndex)) as ContextMessage[]; }, }); - -function isRealUserPrompt(message: ContextMessage): boolean { - if (message.role !== 'user') return false; - const origin = message.origin; - if (origin === undefined || origin.kind === 'user') return true; - return ( - (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && - origin.trigger === 'user-slash' - ); -} diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index c719356be6..6279b19bf2 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,33 +1,8 @@ /** - * `contextMemory` transcript reducer — rebuilds the FULL message history of an - * agent from its `context.*` wire records for UI display (snapshot / messages). + * `contextMemory` domain (L4) — rebuilds display history from the wire journal. * - * The live `ContextModel` (`ContextMemoryService`) rewrites the model-facing - * context on `context.apply_compaction` into `[...keptUserMessages, - * compaction_summary]`, so reading the live context after a compaction loses - * everything before the fold. The wire log keeps every record, though, so this - * reducer re-reduces the `context.*` records with the same semantics as the - * live `ContextMemory` restore, EXCEPT that `context.apply_compaction` KEEPS - * the full history and appends a user-role summary marker — the same view the - * v1 transcript / TUI shows after resume. `foldedLength` tracks what the live - * (folded) `context.history.length` would be, so a caller can detect and - * append an unflushed live tail. - * - * Mirrors v1 `reduceWireRecords` - * (`packages/agent-core/src/services/message/transcript.ts`): - * - `context.append_message` → append (deferred while a tool exchange is open) - * - `context.append_loop_event` → step.begin/content.part/tool.call mutate the - * open assistant; tool.result appends a tool - * message with the raw output; settling a step - * (at its end or at the next begin) drops an - * output-free assistant, mirroring the live fold - * - `context.apply_compaction` → keep the full history, append the user-role - * summary marker, recover `foldedLength` from - * the recorded kept-count fields - * - `context.undo` → remove tail messages (skip injections, stop - * at compaction summaries / clear floor) - * - `context.clear` → keep prior transcript entries but reset the - * folded view + * Supplies transcript consumers with full pre-compaction history and folded + * context length while preserving undo/clear semantics. Scope-agnostic. */ import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; @@ -36,9 +11,9 @@ import type { WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, collectCompactableUserMessages, - isRealUserInput, selectRecentUserMessages, } from './compactionHandoff'; +import { isPromptOwnedInjection, isUndoAnchor } from './conversationTime'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; import { isVacuousContentPart } from './vacuousContent'; @@ -198,9 +173,19 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { if (message.origin?.kind === 'compaction_summary') break; transcript.splice(i, 1); foldedLength = Math.max(0, foldedLength - 1); - if (isRealUserInput(message)) { + if (isUndoAnchor(message)) { removedUserCount++; - if (removedUserCount >= count) break; + if (removedUserCount >= count) { + while ( + i > clearFloor && + isPromptOwnedInjection(transcript[i - 1]!.message, message) + ) { + transcript.splice(i - 1, 1); + i--; + foldedLength = Math.max(0, foldedLength - 1); + } + break; + } } } resetOpenState(); diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts new file mode 100644 index 0000000000..b0196a91a4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -0,0 +1,85 @@ +/** + * `contextMemory` domain (L4) — shared conversation clock and checkpointed + * wire-Model factory. + * + * Defines the undo anchor vocabulary and registers conversation-time Models + * for undo validation. Scope-agnostic. + */ + +import { defineModel, type ModelDef } from '#/wire/model'; + +import type { ContextMessage } from './types'; + +export function isUndoAnchor(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + if (origin === undefined || origin.kind === 'user') return true; + return ( + (origin.kind === 'skill_activation' || origin.kind === 'plugin_command') && + origin.trigger === 'user-slash' + ); +} + +export function isPromptOwnedInjection( + message: ContextMessage, + prompt: ContextMessage, +): boolean { + const origin = message.origin; + return ( + origin?.kind === 'injection' && + origin.ownerPromptId !== undefined && + origin.ownerPromptId === prompt.id + ); +} + +export function isValidUndoCount(count: number): boolean { + return Number.isSafeInteger(count) && count > 0; +} + +export interface Checkpointed { + readonly current: T; + readonly checkpoints: readonly T[]; +} + +export const CHECKPOINTED_MODELS: ModelDef>[] = []; + +export interface CheckpointModelOptions { + readonly onAppendMessage?: (current: T, message: ContextMessage) => T; +} + +export function defineCheckpointedModel( + name: string, + initial: () => T, + opts?: CheckpointModelOptions, +): ModelDef> { + const def = defineModel>( + name, + () => ({ current: initial(), checkpoints: [] }), + { + reducers: { + 'context.append_message': (state, { message }) => { + if (isUndoAnchor(message)) { + return { ...state, checkpoints: [...state.checkpoints, state.current] }; + } + if (opts?.onAppendMessage === undefined) return state; + const current = opts.onAppendMessage(state.current, message); + return current === state.current ? state : { ...state, current }; + }, + 'context.apply_compaction': (state) => + state.checkpoints.length === 0 ? state : { ...state, checkpoints: [] }, + 'context.clear': (state) => + state.checkpoints.length === 0 ? state : { ...state, checkpoints: [] }, + 'context.undo': (state, { count }) => { + if (!isValidUndoCount(count) || state.checkpoints.length < count) return state; + const checkpointIndex = state.checkpoints.length - count; + return { + current: state.checkpoints[checkpointIndex]!, + checkpoints: state.checkpoints.slice(0, checkpointIndex), + }; + }, + }, + }, + ); + CHECKPOINTED_MODELS.push(def as ModelDef>); + return def; +} diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts new file mode 100644 index 0000000000..d65a6739c4 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoParticipants.ts @@ -0,0 +1,62 @@ +/** + * `contextMemory` domain (L4) — Agent-scoped post-undo reconciliation registry. + * + * Hosts state-repair participants for the undo coordinator. Bound at Agent + * scope. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; + +export interface AgentConversationUndoParticipant { + readonly id: string; + reconcileAfterUndo(): Promise; +} + +export interface IAgentConversationUndoParticipantRegistry { + readonly _serviceBrand: undefined; + + register(participant: AgentConversationUndoParticipant): IDisposable; + list(): readonly AgentConversationUndoParticipant[]; +} + +export const IAgentConversationUndoParticipantRegistry = + createDecorator( + 'agentConversationUndoParticipantRegistry', + ); + +class AgentConversationUndoParticipantRegistry + extends Disposable + implements IAgentConversationUndoParticipantRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly participants = new Map(); + + register(participant: AgentConversationUndoParticipant): IDisposable { + if (this.participants.has(participant.id)) { + throw new Error( + `Conversation undo participant "${participant.id}" is already registered`, + ); + } + this.participants.set(participant.id, participant); + return toDisposable(() => { + if (this.participants.get(participant.id) === participant) { + this.participants.delete(participant.id); + } + }); + } + + list(): readonly AgentConversationUndoParticipant[] { + return [...this.participants.values()]; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationUndoParticipantRegistry, + AgentConversationUndoParticipantRegistry, + ScopeActivation.OnScopeCreated, + 'conversationUndoParticipants', +); diff --git a/packages/agent-core-v2/src/agent/contextMemory/types.ts b/packages/agent-core-v2/src/agent/contextMemory/types.ts index dd73e87090..24736b5547 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/types.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/types.ts @@ -33,6 +33,7 @@ export interface PluginCommandOrigin { export interface InjectionOrigin { readonly kind: 'injection'; readonly variant: string; + readonly ownerPromptId?: string; } export interface ShellCommandOrigin { diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 1f4116e57d..85f3c51fa7 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -404,8 +404,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull }; } - /** Scope disposal is the fallback abort path; normal agent teardown aborts - * and awaits the exposed task before disposing the scope. */ + /** Scope disposal is the abort path: tear down any in-flight compaction. */ override dispose(): void { if (this._compacting !== null && !this._compacting.abortController.signal.aborted) { this._compacting.abortController.abort(); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d20a659a16..241a1e25a9 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -146,6 +146,8 @@ export interface IAgentLoopService { cancel(turnId?: number, reason?: unknown): boolean; + tryAcquireQuiescence(): IDisposable | undefined; + /** Resolves once no turn is active and none are queued — the disposal drain * awaited by `agentLifecycle.remove`. */ settled(): Promise; diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 25bc361355..69a67fd771 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -119,8 +119,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private readonly pendingAssignments = new Map>>(); private readonly errorHandlers: LoopErrorHandler[] = []; private readonly pendingTurns: TurnJob[] = []; + private readonly heldAdmissions: HeldAdmission[] = []; private activeTurnJob: TurnJob | undefined; private readonly settleWaiters: Array<() => void> = []; + private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; constructor( @@ -174,6 +176,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { request.abort(); this.rejectAssignment(request, reason); } + for (const { request } of this.heldAdmissions.splice(0)) { + request.abort(); + this.rejectAssignment(request, reason); + } this.maybeSettle(); super.dispose(); } @@ -184,6 +190,18 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { void assignment.catch(() => undefined); this.pendingAssignments.set(request, assignment); + if (this.quiescenceDepth > 0) { + this.heldAdmissions.push({ request, options }); + } else { + this.admit(request, options); + } + return { + assigned: assignment, + abort: (reason) => this.abortRequest(request, reason), + }; + } + + private admit(request: StepRequest, options?: StepEnqueueOptions): void { const active = this.activeTurnJob; switch (request.admission) { case 'newTurn': @@ -206,10 +224,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { this.assignStep(active, request, options); break; } - return { - assigned: assignment, - abort: (reason) => this.abortRequest(request, reason), - }; } private createAndQueueTurn(request: StepRequest): void { @@ -242,10 +256,34 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); } + tryAcquireQuiescence(): IDisposable | undefined { + if (this.disposing) throw abortError('Agent loop disposed'); + if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; + this.quiescenceDepth += 1; + return toDisposable(() => this.releaseQuiescence()); + } + + private releaseQuiescence(): void { + if (this.quiescenceDepth === 0) return; + this.quiescenceDepth -= 1; + if (this.quiescenceDepth > 0 || this.disposing) return; + this.pumpTurns(); + for (const admission of this.heldAdmissions.splice(0)) { + if (admission.request.aborted) continue; + try { + this.admit(admission.request, admission.options); + } catch (error) { + admission.request.abort(); + this.rejectAssignment(admission.request, error); + } + } + this.pumpTurns(); + } + private cancelActiveTurn(turnId: number | undefined, cancellation: unknown): boolean { const job = this.activeTurnJob; if (job === undefined || (turnId !== undefined && job.turn.id !== turnId)) return false; - this.wire.dispatch(cancelTurn({ turnId })); + this.wire.dispatch(cancelTurn({ turnId: job.turn.id, target: 'active' })); job.controller.abort(cancellation); return true; } @@ -255,7 +293,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (index < 0) return false; const [job] = this.pendingTurns.splice(index, 1); if (job === undefined || job.turn.state !== 'queued') return false; - this.wire.dispatch(cancelTurn({ turnId })); + this.wire.dispatch(cancelTurn({ turnId, target: 'queued' })); for (const step of job.steps.values()) step.cancel(cancellation); job.controller.abort(cancellation); job.turn.state = 'cancelled'; @@ -269,12 +307,17 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { return ( this.activeTurnJob?.queue.hasPendingRequests() === true || this.standaloneStepQueue.hasPendingRequests() || - this.pendingTurns.length > 0 + this.pendingTurns.length > 0 || + this.heldAdmissions.some(({ request }) => !request.aborted) ); } settled(): Promise { - if (this.activeTurnJob === undefined && this.pendingTurns.length === 0) { + if ( + this.activeTurnJob === undefined && + this.pendingTurns.length === 0 && + this.heldAdmissions.length === 0 + ) { return Promise.resolve(); } return new Promise((resolve) => { @@ -283,7 +326,11 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private maybeSettle(): void { - if (this.activeTurnJob !== undefined || this.pendingTurns.length > 0) return; + if ( + this.activeTurnJob !== undefined || + this.pendingTurns.length > 0 || + this.heldAdmissions.length > 0 + ) return; if (this.settleWaiters.length === 0) return; const waiters = this.settleWaiters.splice(0); for (const resolve of waiters) resolve(); @@ -339,6 +386,14 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private abortRequest(request: StepRequest, reason?: unknown): boolean { + const heldIndex = this.heldAdmissions.findIndex((entry) => entry.request === request); + if (heldIndex >= 0) { + this.heldAdmissions.splice(heldIndex, 1); + if (!request.abort()) return false; + this.rejectAssignment(request, reason ?? userCancellationReason()); + this.maybeSettle(); + return true; + } for (const job of [this.activeTurnJob, ...this.pendingTurns]) { if (job === undefined) continue; if (job.turn.state === 'queued' && job.request === request) { @@ -387,7 +442,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { } private pumpTurns(): void { - if (this.disposing || this.activeTurnJob !== undefined) return; + if (this.disposing || this.quiescenceDepth > 0 || this.activeTurnJob !== undefined) return; const job = this.pendingTurns.shift(); if (job === undefined) { this.maybeSettle(); @@ -518,6 +573,7 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (step.state === 'queued' || step.state === 'running') step.cancel(reason); } this.activeTurnJob = undefined; + this.maybeSettle(); } registerLoopErrorHandler( @@ -1056,6 +1112,11 @@ interface TurnJob { readonly turn: MutableTurn; } +interface HeldAdmission { + readonly request: StepRequest; + readonly options?: StepEnqueueOptions; +} + interface LoopRuntime { readonly turnId: number; readonly turnSignal: AbortSignal; diff --git a/packages/agent-core-v2/src/agent/loop/turnOps.ts b/packages/agent-core-v2/src/agent/loop/turnOps.ts index f76ee74cf6..0a6714e92c 100644 --- a/packages/agent-core-v2/src/agent/loop/turnOps.ts +++ b/packages/agent-core-v2/src/agent/loop/turnOps.ts @@ -1,22 +1,9 @@ /** - * `loop` domain (L4) — wire Model (`TurnModel`) and the Ops that bookkeep the - * agent's turn lifecycle on the wire. + * `loop` domain (L4) — persists and restores monotonically increasing turn + * identity. * - * Declares the next turn id as a wire Model (initial `0`). The persisted - * `turn.prompt` record carries exactly v1's field set (`{ input, origin }` — - * no `turnId`), and `apply` mirrors v1's `restorePrompt()`: every record - * advances the counter by one, so the counter is restored by counting - * turn starts. Every turn is started by `loopService.enqueue` admitting a - * request that creates a new Turn, which dispatches one - * `turn.prompt` per start. As a belt-and-suspenders for v1-written logs whose - * internally-driven turns (goal continuations) have no `turn.prompt` record, - * `TurnModel` also registers a cross-model reducer on - * `context.append_loop_event` that raises the counter past any `turnId` - * observed in a replayed loop event — the v1 `observeRestoredTurnId` - * semantics. The `turn.started` / `turn.ended` / `error` signals are not part - * of this Op set and remain on their existing path (published by the loop - * service around a run). Consumed by the Agent-scope `loopService` (which - * reads the next turn id on admission). + * Owns the next available turn id, including cancelled queued reservations and + * legacy loop-event observations. Consumed by the Agent-scope `loopService`. */ import { z } from 'zod'; @@ -27,22 +14,27 @@ import type { PromptOrigin } from '#/agent/contextMemory/types'; export interface TurnModelState { readonly nextTurnId: number; + readonly cancelledTurnIds: readonly number[]; } -export const TurnModel = defineModel('turn', () => ({ nextTurnId: 0 }), { - reducers: { - 'context.append_loop_event': (state, { event }) => { - if (event.type === 'tool.result' || event.turnId === undefined) { - return state; - } +export const TurnModel = defineModel( + 'turn', + () => ({ nextTurnId: 0, cancelledTurnIds: [] }), + { + reducers: { + 'context.append_loop_event': (state, { event }) => { + if (event.type === 'tool.result' || event.turnId === undefined) { + return state; + } - const turnId = Number.parseInt(event.turnId, 10); - return Number.isInteger(turnId) && turnId >= state.nextTurnId - ? { nextTurnId: turnId + 1 } - : state; + const turnId = Number.parseInt(event.turnId, 10); + return Number.isInteger(turnId) && turnId >= state.nextTurnId + ? advanceTurnClock(state, turnId + 1) + : state; + }, }, }, -}); +); const turnInputShape = { input: z.custom(), @@ -59,7 +51,7 @@ declare module '#/wire/types' { export const promptTurn = TurnModel.defineOp('turn.prompt', { schema: z.object(turnInputShape), - apply: (s) => ({ nextTurnId: s.nextTurnId + 1 }), + apply: (s) => advanceTurnClock(s, s.nextTurnId + 1), }); export const steerTurn = TurnModel.defineOp('turn.steer', { @@ -68,6 +60,27 @@ export const steerTurn = TurnModel.defineOp('turn.steer', { }); export const cancelTurn = TurnModel.defineOp('turn.cancel', { - schema: z.object({ turnId: z.number().optional() }), - apply: (s) => s, + schema: z.object({ + turnId: z.number().optional(), + target: z.enum(['active', 'queued']).optional(), + }), + apply: (s, { turnId, target }) => { + if (target === undefined || turnId === undefined || turnId < s.nextTurnId) return s; + return advanceTurnClock(s, s.nextTurnId, [...s.cancelledTurnIds, turnId]); + }, }); + +function advanceTurnClock( + state: TurnModelState, + nextTurnId: number, + cancelledTurnIds: readonly number[] = state.cancelledTurnIds, +): TurnModelState { + const pendingCancellations = new Set( + cancelledTurnIds.filter((turnId) => turnId >= nextTurnId), + ); + while (pendingCancellations.delete(nextTurnId)) nextTurnId += 1; + return { + nextTurnId, + cancelledTurnIds: [...pendingCancellations].toSorted((a, b) => a - b), + }; +} diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 8bccc920b0..1781af0184 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -7,13 +7,14 @@ * reference-only fact. * * The Model holds the persistent, replayable fields — whether plan mode is - * active, the plan id, and the last recorded revision version per plan id. - * The lifecycle records keep exactly v1's field set (`{ id }`); the plan file - * path is NOT persisted — it is derived from the id at read time - * (`planService.planFilePathFor`), matching v1's `restoreEnter`. Plan - * content is recorded separately: every ExitPlanMode submit snapshots the - * plan file into blob storage and persists a `plan.revision` record carrying - * only the reference (`{ id, version, path, sha256, bytes }`, `path` + * active, the plan id, and the last recorded revision version per plan id — + * wrapped in `contextMemory`'s checkpoint protocol so plan mode stays aligned + * with conversation undo. The lifecycle records keep exactly v1's field set + * (`{ id }`); the plan file path is NOT persisted — it is derived from the id + * at read time (`planService.planFilePathFor`), matching v1's `restoreEnter`. + * Plan content is recorded separately: every ExitPlanMode submit snapshots + * the plan file into blob storage and persists a `plan.revision` record + * carrying only the reference (`{ id, version, path, sha256, bytes }`, `path` * homeDir-relative) — never the content. `revisionCount` tracks the latest * version per plan id so `recordRevision` can mint the next version * replay-consistently; it is kept across enter/exit so a re-entered plan id @@ -34,7 +35,10 @@ import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { + defineCheckpointedModel, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; export interface PlanState { readonly active: boolean; @@ -42,14 +46,16 @@ export interface PlanState { readonly revisionCount?: Readonly>; } -export const PlanModel = defineModel('plan', () => ({ active: false })); +export type PlanModelState = Checkpointed; + +export const PlanModel = defineCheckpointedModel('plan', (): PlanState => ({ active: false })); export const planModeEnter = PlanModel.defineOp('plan_mode.enter', { schema: z.object({ id: z.string() }), apply: (s, p) => - s.active && s.id === p.id + s.current.active && s.current.id === p.id ? s - : { active: true, id: p.id, revisionCount: s.revisionCount }, + : { ...s, current: { active: true, id: p.id, revisionCount: s.current.revisionCount } }, toEvent: () => ({ type: 'agent.status.updated' as const, planMode: true }), }); @@ -64,13 +70,19 @@ declare module '#/wire/types' { export const planModeCancel = PlanModel.defineOp('plan_mode.cancel', { schema: z.object({ id: z.string().optional() }), - apply: (s) => (s.active === false ? s : { active: false, revisionCount: s.revisionCount }), + apply: (s) => + s.current.active + ? { ...s, current: { active: false, revisionCount: s.current.revisionCount } } + : s, toEvent: () => ({ type: 'agent.status.updated' as const, planMode: false }), }); export const planModeExit = PlanModel.defineOp('plan_mode.exit', { schema: z.object({ id: z.string().optional() }), - apply: (s) => (s.active === false ? s : { active: false, revisionCount: s.revisionCount }), + apply: (s) => + s.current.active + ? { ...s, current: { active: false, revisionCount: s.current.revisionCount } } + : s, toEvent: () => ({ type: 'agent.status.updated' as const, planMode: false }), }); @@ -98,7 +110,10 @@ export const planRevision = PlanModel.defineOp('plan.revision', { }), apply: (s, p) => ({ ...s, - revisionCount: { ...s.revisionCount, [p.id]: p.version }, + current: { + ...s.current, + revisionCount: { ...s.current.revisionCount, [p.id]: p.version }, + }, }), toEvent: (p) => ({ type: 'plan.revision' as const, diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index a81cc6e3a2..c6690ed7c4 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -42,6 +42,7 @@ import type { BeforeToolExecuteEvent, ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; +import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; @@ -74,6 +75,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { @IBlobStore private readonly blobs: IBlobStore, @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentTelemetryContextService private readonly telemetryContext: IAgentTelemetryContextService, + @IEventBus eventBus: IEventBus, @IWireService private readonly wire: IWireService, @ISessionContext private readonly sessionCtx: ISessionContext, @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, @@ -93,6 +95,15 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { await next(); }), ); + this._register( + eventBus.subscribe('context.undone', () => { + this.restoreTelemetryMode(); + eventBus.publish({ + type: 'agent.status.updated', + planMode: this.isActive, + }); + }), + ); this._register(new PlanModeInjection(dynamicInjector, this, this.context, states)); this._register(this.registerPlanGuard(toolExecutor)); @@ -152,19 +163,17 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } private get isActive(): boolean { - return this.wire.getModel(PlanModel).active; + return this.wire.getModel(PlanModel).current.active; } private currentPlanFilePath(): PlanFilePath { - const state = this.wire.getModel(PlanModel); + const state = this.wire.getModel(PlanModel).current; if (!state.active || state.id === undefined) return null; return this.planFilePathFor(state.id); } private restoreTelemetryMode(): void { - if (this.isActive) { - this.telemetryContext.set({ mode: 'plan' }); - } + this.telemetryContext.set({ mode: this.isActive ? 'plan' : 'agent' }); } private createPlanId(): string { @@ -211,7 +220,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } async recordRevision(): Promise { - const state = this.wire.getModel(PlanModel); + const state = this.wire.getModel(PlanModel).current; if (!state.active || state.id === undefined) return; const id = state.id; const content = await this.hostFs.readText(this.planFilePathFor(id)); @@ -232,7 +241,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { } async status(): Promise { - const state = this.wire.getModel(PlanModel); + const state = this.wire.getModel(PlanModel).current; if (!state.active || state.id === undefined) return null; const path = this.planFilePathFor(state.id); let content = ''; diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 8f9110a246..d5045dd025 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -55,7 +55,6 @@ export interface IAgentPromptService { abort(promptId: string, reason?: Error): boolean; inject(message: ContextMessage): Promise; retry(): Promise; - undo(count: number): number; clear(): void; readonly hooks: Hooks<{ onBeforeSubmitPrompt: PromptSubmitContext }>; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts new file mode 100644 index 0000000000..e31a469823 --- /dev/null +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -0,0 +1,66 @@ +/** + * `prompt` domain (L4) — safe, displayable metadata text derived from prompts. + * + * Shared by prompt submission and undo projection so `lastPrompt` uses one + * normalization, redaction, and length limit, with image captions supplied by + * the `media` domain. + */ + +import type { ContentPart } from '#/kosong/contract/message'; +import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; + +const MAX_TITLE_LENGTH = 200; +const MAX_LAST_PROMPT_LENGTH = 4000; + +export function titleFromPromptMetadataText(text: string): string { + return text.slice(0, MAX_TITLE_LENGTH); +} + +export function promptMetadataTextFromContentParts( + parts: readonly ContentPart[], +): string | undefined { + const texts: string[] = []; + for (const part of parts) { + const text = promptPartText(part); + if (text !== undefined) texts.push(text); + } + return promptMetadataTextFromText(texts.join('\n')); +} + +export function promptMetadataTextFromText(text: string): string | undefined { + const sanitized = text + .replaceAll( + /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, + '[redacted]', + ) + .replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]') + .replaceAll( + /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, + '$1=[redacted]', + ) + .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') + .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') + .replaceAll(/\p{Cc}+/gu, ' ') + .replaceAll(/\s+/g, ' ') + .trim(); + + if (sanitized.length === 0) return undefined; + return sanitized.slice(0, MAX_LAST_PROMPT_LENGTH); +} + +function promptPartText(part: ContentPart): string | undefined { + switch (part.type) { + case 'text': { + const { text } = extractImageCompressionCaptions(part.text); + return text.trim().length === 0 ? undefined : text; + } + case 'image_url': + return '[image]'; + case 'audio_url': + return '[audio]'; + case 'video_url': + return '[video]'; + case 'think': + return undefined; + } +} diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 6d93a7f5b2..aeed006505 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -19,7 +19,6 @@ import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; import { userCancellationReason } from '#/_base/utils/abort'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { newMessageId } from '#/agent/contextMemory/messageId'; -import { formatUndoUnavailableMessage, precheckUndo } from '#/agent/contextMemory/contextOps'; import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService, type Turn, type TurnResult } from '#/agent/loop/loop'; @@ -174,13 +173,6 @@ export class AgentPromptService implements IAgentPromptService { async retry(): Promise { return (await this.loop.enqueue(new RetryStepRequest()).assigned).turn; } - undo(count: number): number { - if (count <= 0) return 0; - const check = precheckUndo(this.context.get(), count); - if (!check.ok) throw new Error2(ErrorCodes.SESSION_UNDO_UNAVAILABLE, formatUndoUnavailableMessage(check), { details: { reason: check.reason, requestedCount: count, undoableCount: check.undoable } }); - return this.context.undo(count).removedCount; - } - clear(): void { for (const item of this.pending.slice()) this.abort(item.id); if (this.active !== undefined) this.abort(this.active.id); @@ -246,8 +238,15 @@ export class AgentPromptService implements IAgentPromptService { return { message: captions.length === 0 ? message : { ...message, content: parts }, captions }; } private appendPrompt(message: ContextMessage, captions: readonly string[]): void { - for (const caption of captions) this.reminders.appendSystemReminder(caption, { kind: 'injection', variant: 'image_compression' }); - if (message.content.length > 0) this.context.append(message); + const ownerPromptId = message.id ?? newMessageId(); + for (const caption of captions) { + this.reminders.appendSystemReminder(caption, { + kind: 'injection', + variant: 'image_compression', + ownerPromptId, + }); + } + if (message.content.length > 0) this.context.append({ ...message, id: ownerPromptId }); } private async deliverToolResult(ctx: ToolDidExecuteContext): Promise { const delivery = ctx.result.delivery; if (delivery === undefined) return; diff --git a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts index c4ec2ee3d8..40e204c13a 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptStepRequests.ts @@ -16,12 +16,14 @@ */ import { USER_PROMPT_ORIGIN, type ContextMessage } from '#/agent/contextMemory/types'; +import { newMessageId } from '#/agent/contextMemory/messageId'; import { StepRequest, type StepRequestOptions, type TurnSeed } from '#/agent/loop/stepRequest'; import { gateImageFormatParts } from '#/agent/media/image-compress'; import type { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; abstract class UserMessageStepRequest extends StepRequest { protected readonly message: ContextMessage; + private readonly ownerPromptId: string; constructor( message: ContextMessage, @@ -30,7 +32,12 @@ abstract class UserMessageStepRequest extends StepRequest { options?: StepRequestOptions, ) { super(options); - this.message = { ...message, content: gateImageFormatParts(message.content) }; + this.ownerPromptId = message.id ?? newMessageId(); + this.message = { + ...message, + id: this.ownerPromptId, + content: gateImageFormatParts(message.content), + }; } override get turnSeed(): TurnSeed { @@ -42,6 +49,7 @@ abstract class UserMessageStepRequest extends StepRequest { this.reminders.appendSystemReminder(caption, { kind: 'injection', variant: 'image_compression', + ownerPromptId: this.ownerPromptId, }); } } diff --git a/packages/agent-core-v2/src/agent/rpc/core-api.ts b/packages/agent-core-v2/src/agent/rpc/core-api.ts index 8b4261d3c5..0b6668471e 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -314,7 +314,7 @@ export interface AgentAPI { prompt: (payload: PromptPayload) => PromptLaunchResult | undefined; steer: (payload: SteerPayload) => PromptLaunchResult | undefined; cancel: (payload: CancelPayload) => void; - undoHistory: (payload: UndoHistoryPayload) => number; + undoHistory: (payload: UndoHistoryPayload) => Promise; setPermission: (payload: SetPermissionPayload) => void; cancelCompaction: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; diff --git a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts index 3e15261ced..0bf80ce6a0 100644 --- a/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts +++ b/packages/agent-core-v2/src/agent/rpc/prompt-metadata.ts @@ -7,11 +7,14 @@ * prompt adapter so both surfaces keep the same easy-title behavior. */ -import type { ContentPart } from '#/kosong/contract/message'; import type { IEventService } from '#/app/event/event'; import type { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; +import { + promptMetadataTextFromContentParts, + promptMetadataTextFromText, + titleFromPromptMetadataText, +} from '#/agent/prompt/promptMetadataText'; import type { ActivatePluginCommandPayload, @@ -19,33 +22,16 @@ import type { PromptPayload, } from './core-api'; -const MAX_TITLE_LENGTH = 200; -const MAX_LAST_PROMPT_LENGTH = 4000; - -export function titleFromPromptMetadataText(text: string): string { - return text.slice(0, MAX_TITLE_LENGTH); -} +export { promptMetadataTextFromContentParts, titleFromPromptMetadataText }; export function promptMetadataTextFromPayload(payload: PromptPayload): string | undefined { return promptMetadataTextFromContentParts(payload.input); } -export function promptMetadataTextFromContentParts( - parts: readonly ContentPart[], -): string | undefined { - const texts: string[] = []; - for (const part of parts) { - const text = promptPartText(part); - if (text !== undefined) texts.push(text); - } - return sanitizeAndTruncatePromptText(texts.join('\n'), MAX_LAST_PROMPT_LENGTH); -} - export function promptMetadataTextFromSkill(payload: ActivateSkillPayload): string | undefined { const args = payload.args?.trim(); - return sanitizeAndTruncatePromptText( + return promptMetadataTextFromText( args === undefined || args.length === 0 ? `/${payload.name}` : `/${payload.name} ${args}`, - MAX_LAST_PROMPT_LENGTH, ); } @@ -54,9 +40,8 @@ export function promptMetadataTextFromPluginCommand( ): string | undefined { const args = payload.args?.trim(); const command = `/${payload.pluginId}:${payload.commandName}`; - return sanitizeAndTruncatePromptText( + return promptMetadataTextFromText( args === undefined || args.length === 0 ? command : `${command} ${args}`, - MAX_LAST_PROMPT_LENGTH, ); } @@ -98,41 +83,3 @@ export async function applyPromptMetadataUpdate( }, }); } - -function promptPartText(part: ContentPart): string | undefined { - switch (part.type) { - case 'text': { - const { text } = extractImageCompressionCaptions(part.text); - return text.trim().length === 0 ? undefined : text; - } - case 'image_url': - return '[image]'; - case 'audio_url': - return '[audio]'; - case 'video_url': - return '[video]'; - case 'think': - return undefined; - } -} - -function sanitizeAndTruncatePromptText(text: string, maxLength: number): string | undefined { - const sanitized = text - .replaceAll( - /-----BEGIN [^-]*PRIVATE KEY-----[\s\S]*?-----END [^-]*PRIVATE KEY-----/gi, - '[redacted]', - ) - .replaceAll(/\b(authorization)\s*:\s*bearer\s+\S+/gi, '$1: Bearer [redacted]') - .replaceAll( - /\b(api[_-]?key|token|secret|password|passwd|pwd)\b\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi, - '$1=[redacted]', - ) - .replaceAll(/\bsk-[A-Za-z0-9_-]{12,}\b/g, '[redacted]') - .replaceAll(/\b[A-Za-z0-9][A-Za-z0-9+/=_-]{39,}\b/g, '[redacted]') - .replaceAll(/\p{Cc}+/gu, ' ') - .replaceAll(/\s+/g, ' ') - .trim(); - - if (sanitized.length === 0) return undefined; - return sanitized.slice(0, maxLength); -} diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 75b78cab5f..bb7ffef1d4 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -18,6 +18,7 @@ import { IPluginService } from '#/app/plugin/plugin'; import { ProfileError } from '#/agent/profile/profile'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAgentSkillService } from '#/agent/skill/skill'; @@ -63,6 +64,8 @@ export class AgentRPCService implements IAgentRPCService { constructor( @IAgentPromptService private readonly promptService: IAgentPromptService, + @IAgentConversationUndoService + private readonly conversationUndo: IAgentConversationUndoService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @@ -126,10 +129,8 @@ export class AgentRPCService implements IAgentRPCService { this.loop.cancel(turnId); } - undoHistory(payload: UndoHistoryPayload): number { - const undone = this.promptService.undo(payload.count); - this.telemetry.track2('conversation_undo', { count: payload.count }); - return undone; + async undoHistory(payload: UndoHistoryPayload): Promise { + return this.conversationUndo.undo(payload.count); } setPermission(payload: SetPermissionPayload): void { diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index d7a2fc5d8e..cc454d3dec 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -31,8 +31,11 @@ * into `agentState` (`IAgentStateService`) and read/written through it; the * live `tasks` registry stays a plain field because a `ManagedTask` holds * resources (promise chains, an `AbortController`, task handles) that must - * not be snapshotted, as does the `persistence` construction-time helper. - * Bound at Agent scope. + * not be snapshotted, as do the `persistence` construction-time helper and + * the notification delivery machinery (`buildingNotificationKeys`, + * `pendingNotificationRequests`, `notificationRestoreQueue`). + * Notification delivery follows conversation undo through the checkpoint and + * reconciliation contracts. Bound at Agent scope. */ import { randomBytes } from 'node:crypto'; @@ -43,6 +46,7 @@ import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/ import type { ContentPart } from '#/kosong/contract/message'; import { Disposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; import { defineState } from '#/_base/state/stateRegistry'; import { abortable, @@ -50,6 +54,8 @@ import { } from '#/_base/utils/abort'; import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; +import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -70,7 +76,6 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { defineModel } from '#/wire/model'; import { IWireService } from '#/wire/wire'; import { IAgentTaskService, @@ -116,17 +121,15 @@ interface AgentTaskNotificationBuildContext { readonly notification: AgentTaskNotification; } -const TaskNotificationDeliveryModel = defineModel( +const TaskNotificationDeliveryModel = defineCheckpointedModel( 'task.notificationDelivery', - () => [], + (): readonly string[] => [], { - reducers: { - 'context.append_message': (state, payload: { message?: unknown }) => { - const origin = taskOriginFromMessage(payload.message); - if (origin === undefined) return state; - const key = notificationKey(origin); - return state.includes(key) ? state : [...state, key]; - }, + onAppendMessage: (current, message) => { + const origin = taskOriginFromMessage(message); + if (origin === undefined) return current; + const key = notificationKey(origin); + return current.includes(key) ? current : [...current, key]; }, }, ); @@ -210,7 +213,10 @@ declare module '#/app/event/eventBus' { } export class TaskNotificationStepRequest extends MessageStepRequest { - constructor(message: ContextMessage) { + constructor( + message: ContextMessage, + private readonly onWillDeliver?: () => void, + ) { super(message, { kind: 'task_notification', mergeable: true, @@ -218,6 +224,10 @@ export class TaskNotificationStepRequest extends MessageStepRequest { admission: 'activeOrNewTurn', }); } + + override onWillMaterialize(): void { + this.onWillDeliver?.(); + } } export const taskGhostsKey = defineState>( @@ -241,7 +251,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { declare readonly _serviceBrand: undefined; private readonly tasks = new Map(); + private readonly buildingNotificationKeys = new Set(); + private readonly pendingNotificationRequests = new Map(); private readonly persistence: AgentTaskPersistence; + private notificationRestoreQueue: Promise = Promise.resolve(); constructor( @ITelemetryService private readonly telemetry: ITelemetryService, @@ -256,6 +269,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IEventBus private readonly eventBus: IEventBus, @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentConversationUndoParticipantRegistry + undoParticipants: IAgentConversationUndoParticipantRegistry, + @ILogService private readonly log: ILogService, @IAgentStateService private readonly states: IAgentStateService, ) { super(); @@ -274,9 +290,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { byteStore, fallbackRoot, ); + this._register( + undoParticipants.register({ + id: 'task.notificationDelivery', + reconcileAfterUndo: () => this.reconcileNotificationDeliveryAfterUndo(), + }), + ); this._register( this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => { - for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) { + for (const key of this.wire.getModel(TaskNotificationDeliveryModel).current) { this.deliveredNotificationKeys.add(key); } await this.restoreAfterReplay(); @@ -519,6 +541,21 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return result; } + private async reconcileNotificationDeliveryAfterUndo(): Promise { + const restoredKeys = new Set(this.wire.getModel(TaskNotificationDeliveryModel).current); + for (const [key, request] of this.pendingNotificationRequests) { + if (request.aborted) this.clearPendingNotification(key, request); + } + this.deliveredNotificationKeys.clear(); + for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); + for (const key of this.scheduledNotificationKeys) { + if (restoredKeys.has(key) || !this.pendingNotificationRequests.has(key)) { + this.scheduledNotificationKeys.delete(key); + } + } + await this.restoreAgentTaskNotifications(); + } + persistOutput(taskId: string): void { const entry = this.tasks.get(taskId); if (entry === undefined) return; @@ -1025,7 +1062,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { if (!this.isDetached(entry)) return; entry.terminalFired = true; const info = this.toInfo(entry); - void this.notifyAgentTask(info).catch(() => { }); + void this.notifyAgentTask(info).catch((error) => { + this.log.error('task notification delivery failed', { taskId: info.taskId, error }); + }); this.recordTaskTerminated(info, this.retainedOutputTail(entry)); } @@ -1057,17 +1096,42 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private async notifyAgentTask(info: AgentTaskInfo): Promise { const context = await this.buildAgentTaskNotificationContext(info); if (context === undefined) return; - const request = new TaskNotificationStepRequest({ - role: 'user', - content: [...context.content], - toolCalls: [], - origin: context.origin, - }); - this.loop.enqueue(request); - this.fireNotificationHook(context.notification); + const key = notificationKey(context.origin); + const request = new TaskNotificationStepRequest( + { + role: 'user', + content: [...context.content], + toolCalls: [], + origin: context.origin, + }, + () => this.fireNotificationHook(context.notification), + ); + this.pendingNotificationRequests.set(key, request); + try { + const receipt = this.loop.enqueue(request); + void receipt.assigned + .then(({ step }) => step.result) + .then( + () => { + if (request.aborted) this.clearPendingNotification(key, request); + }, + () => this.clearPendingNotification(key, request), + ); + } catch (error) { + this.clearPendingNotification(key, request); + throw error; + } } - private async restoreAgentTaskNotifications(): Promise { + private restoreAgentTaskNotifications(): Promise { + const restore = this.notificationRestoreQueue.then(() => + this.restoreAgentTaskNotificationsNow(), + ); + this.notificationRestoreQueue = restore.catch(() => {}); + return restore; + } + + private async restoreAgentTaskNotificationsNow(): Promise { for (const info of this.list(false)) { if (!isAgentTaskTerminal(info.status)) continue; await this.restoreAgentTaskNotification(info); @@ -1098,35 +1162,51 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { notificationId: `task:${info.taskId}:${info.status}`, }; const key = notificationKey(origin); + if (this.buildingNotificationKeys.has(key)) return undefined; if (this.scheduledNotificationKeys.has(key)) return undefined; if (this.deliveredNotificationKeys.has(key)) return undefined; if (this.hasDeliveredNotification(key)) return undefined; - this.scheduledNotificationKeys.add(key); - - let output = await this.getOutputSnapshot(info.taskId, 0); - if (!output.fullOutputAvailable) { - output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + this.buildingNotificationKeys.add(key); + try { + let output = emptyOutputSnapshot(); + try { + output = await this.getOutputSnapshot(info.taskId, 0); + if (!output.fullOutputAvailable) { + output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + } + } catch (error) { + this.log.error('task notification output read failed; delivering without output', { + taskId: info.taskId, + error, + }); + } + if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; + if (this.scheduledNotificationKeys.has(key)) return undefined; + if (this.deliveredNotificationKeys.has(key)) return undefined; + if (this.hasDeliveredNotification(key)) return undefined; + this.scheduledNotificationKeys.add(key); + const notification: AgentTaskNotification = { + id: origin.notificationId, + category: 'task', + type: `task.${info.status}`, + source_kind: 'background_task', + source_id: info.taskId, + agent_id: info.kind === 'agent' ? info.agentId : undefined, + title: `Background ${info.kind} ${info.status}`, + severity: info.status === 'completed' ? 'info' : 'warning', + body: buildAgentTaskNotificationBody(info), + children: agentTaskNotificationChildren(output), + }; + const content = [ + { + type: 'text', + text: renderNotificationXml(notification), + }, + ] as const; + return { content, origin, notification }; + } finally { + this.buildingNotificationKeys.delete(key); } - if (this.isTerminalNotificationSuppressed(info.taskId)) return undefined; - const notification: AgentTaskNotification = { - id: origin.notificationId, - category: 'task', - type: `task.${info.status}`, - source_kind: 'background_task', - source_id: info.taskId, - agent_id: info.kind === 'agent' ? info.agentId : undefined, - title: `Background ${info.kind} ${info.status}`, - severity: info.status === 'completed' ? 'info' : 'warning', - body: buildAgentTaskNotificationBody(info), - children: agentTaskNotificationChildren(output), - }; - const content = [ - { - type: 'text', - text: renderNotificationXml(notification), - }, - ] as const; - return { content, origin, notification }; } private fireNotificationHook(notification: AgentTaskNotification): void { @@ -1149,7 +1229,18 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } private markDeliveredNotification(origin: TaskNotificationOrigin): void { - this.deliveredNotificationKeys.add(notificationKey(origin)); + const key = notificationKey(origin); + this.scheduledNotificationKeys.delete(key); + this.pendingNotificationRequests.delete(key); + this.deliveredNotificationKeys.add(key); + } + + private clearPendingNotification(key: string, request: TaskNotificationStepRequest): void { + if (this.pendingNotificationRequests.get(key) !== request) return; + this.pendingNotificationRequests.delete(key); + if (!this.deliveredNotificationKeys.has(key) && !this.hasDeliveredNotification(key)) { + this.scheduledNotificationKeys.delete(key); + } } private hasDeliveredNotification(key: string): boolean { diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index e6422c3ccb..ce22e42b26 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -75,10 +75,7 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele this._register( eventBus.subscribe('context.spliced', (splice) => { if (splice.deleteCount === 0 || this.pendingLoaded.size === 0) return; - const landed = collectLoadedDynamicToolNames(this.context.get()); - for (const name of this.pendingLoaded) { - if (!landed.has(name)) this.pendingLoaded.delete(name); - } + this.dropPendingLoadedNotLanded(); }), ); } @@ -87,6 +84,14 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele return this.states.get(toolSelectPendingLoadedKey); } + private dropPendingLoadedNotLanded(): void { + if (this.pendingLoaded.size === 0) return; + const landed = collectLoadedDynamicToolNames(this.context.get()); + for (const name of this.pendingLoaded) { + if (!landed.has(name)) this.pendingLoaded.delete(name); + } + } + enabled(): boolean { const capabilities = this.profile.getModelCapabilities(); return ( diff --git a/packages/agent-core-v2/src/agent/undo/undo.ts b/packages/agent-core-v2/src/agent/undo/undo.ts new file mode 100644 index 0000000000..9ccec70307 --- /dev/null +++ b/packages/agent-core-v2/src/agent/undo/undo.ts @@ -0,0 +1,23 @@ +/** + * `undo` domain (L6) — Agent-scoped conversation undo contract. + * + * Defines the availability and idle-only execution surface shared by every + * undo entry point. Bound at Agent scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface UndoAvailability { + readonly maxTurns: number; + readonly stoppedAtCompaction: boolean; +} + +export interface IAgentConversationUndoService { + readonly _serviceBrand: undefined; + + availability(): UndoAvailability; + undo(turns: number): Promise; +} + +export const IAgentConversationUndoService: ServiceIdentifier = + createDecorator('agentConversationUndoService'); diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts new file mode 100644 index 0000000000..9441dec57c --- /dev/null +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -0,0 +1,248 @@ +/** + * `undo` domain (L6) — `IAgentConversationUndoService` implementation. + * + * Owns idle conversation undo coordination and restored observable state. + * Coordinates `contextMemory`, undo participants, `fullCompaction`, + * `loop`, `prompt`, Agent and Session identity, `sessionMetadata`, `event`, + * `eventBus`, `telemetry`, and `wire`. Bound at Agent scope. + */ + +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, ScopeActivation, registerScopedService } from '#/_base/di/scope'; +import { ILogService } from '#/_base/log/log'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; +import { + computeUndoCut, + formatUndoUnavailableMessage, + precheckUndo, +} from '#/agent/contextMemory/contextOps'; +import { + CHECKPOINTED_MODELS, + isUndoAnchor, + isValidUndoCount, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { promptMetadataTextFromContentParts } from '#/agent/prompt/promptMetadataText'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IEventService } from '#/app/event/event'; +import { IEventBus } from '#/app/event/eventBus'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, Error2 } from '#/errors'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IWireService } from '#/wire/wire'; + +import { IAgentConversationUndoService, type UndoAvailability } from './undo'; + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'context.undone': { turns: number }; + } +} + +export class AgentConversationUndoService + extends Disposable + implements IAgentConversationUndoService +{ + declare readonly _serviceBrand: undefined; + + private undoQueue: Promise = Promise.resolve(); + + constructor( + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, + @IAgentPromptService private readonly prompt: IAgentPromptService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentConversationUndoParticipantRegistry + private readonly participants: IAgentConversationUndoParticipantRegistry, + @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @ISessionContext private readonly session: ISessionContext, + @ISessionMetadata private readonly metadata: ISessionMetadata, + @IEventService private readonly eventService: IEventService, + @IEventBus private readonly eventBus: IEventBus, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IWireService private readonly wire: IWireService, + @ILogService private readonly log: ILogService, + ) { + super(); + } + + availability(): UndoAvailability { + const cut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); + const maxTurns = Math.min(cut.removedCount, this.checkpointDepth().depth); + return { + maxTurns, + stoppedAtCompaction: cut.stoppedAtCompaction || maxTurns < cut.removedCount, + }; + } + + async undo(turns: number): Promise { + if (!isValidUndoCount(turns)) { + throw new Error2( + ErrorCodes.REQUEST_INVALID, + 'Undo count must be a positive safe integer', + { details: { field: 'count' } }, + ); + } + const run = this.undoQueue.then(() => this.undoNow(turns)); + this.undoQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async undoNow(turns: number): Promise { + let quiescence: IDisposable | undefined; + try { + quiescence = this.loop.tryAcquireQuiescence(); + if (quiescence === undefined) { + throw this.busyError('loop'); + } + if (this.fullCompaction.compacting !== null) { + throw this.busyError('compaction'); + } + this.assertUndoAvailable(turns); + this.context.undo(turns); + await this.flushAfterCommit('context cut'); + await this.reconcileParticipants(); + await this.flushAfterCommit('state reconciliation'); + await this.reconcileLastPromptSafely(); + this.telemetry.track2('conversation_undo', { count: turns }); + this.eventBus.publish({ type: 'context.undone', turns }); + return turns; + } finally { + quiescence?.dispose(); + } + } + + private checkpointDepth(): { depth: number; model: string } { + let depth = Number.POSITIVE_INFINITY; + let model = ''; + for (const def of CHECKPOINTED_MODELS) { + const state = this.wire.getModel(def) as Checkpointed; + if (state.checkpoints.length < depth) { + depth = state.checkpoints.length; + model = def.name; + } + } + return { depth, model }; + } + + private busyError(reason: 'loop' | 'compaction'): Error2 { + const message = reason === 'loop' + ? 'Cannot undo while a turn is active or queued. Wait for it to finish, then retry.' + : 'Cannot undo while conversation compaction is running. Wait for it to finish, then retry.'; + return new Error2(ErrorCodes.SESSION_BUSY, message, { details: { reason } }); + } + + private assertUndoAvailable(turns: number): void { + const check = precheckUndo(this.context.get(), turns); + if (!check.ok) { + throw new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage(check), + { + details: { + reason: check.reason, + requestedCount: check.requested, + undoableCount: check.undoable, + }, + }, + ); + } + const { depth, model } = this.checkpointDepth(); + if (depth >= turns) return; + // A compaction explains missing checkpoints (they are cleared at the + // boundary); without one, a checkpointed model failed to track an anchor. + const fullCut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); + const reason = fullCut.stoppedAtCompaction ? 'compaction_boundary' : 'checkpoint_lost'; + throw new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage({ + ok: false, + reason, + requested: turns, + undoable: depth, + }), + { + details: { + reason, + requestedCount: turns, + undoableCount: depth, + model, + }, + }, + ); + } + + private async reconcileParticipants(): Promise { + const participants = this.participants.list(); + const results = await Promise.allSettled( + participants.map((participant) => participant.reconcileAfterUndo()), + ); + results.forEach((result, index) => { + if (result.status === 'fulfilled') return; + this.log.error('undo participant reconciliation failed', { + participantId: participants[index]?.id, + error: result.reason, + }); + }); + } + + private async reconcileLastPromptSafely(): Promise { + try { + await this.reconcileLastPrompt(); + } catch (error) { + this.log.error('undo lastPrompt reconciliation failed', { error }); + } + } + + private async flushAfterCommit(stage: string): Promise { + try { + await this.wire.flush(); + } catch (error) { + this.log.error('undo wire flush failed after in-memory commit', { stage, error }); + throw error; + } + } + + private async reconcileLastPrompt(): Promise { + if (this.agentCtx.agentId !== MAIN_AGENT_ID) return; + const pending = this.prompt.list().pending.at(-1); + let lastPrompt = pending === undefined + ? undefined + : promptMetadataTextFromContentParts(pending.message.content); + if (lastPrompt === undefined) { + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]!; + if (!isUndoAnchor(message)) continue; + lastPrompt = promptMetadataTextFromContentParts(message.content); + if (lastPrompt !== undefined) break; + } + } + await this.metadata.update({ lastPrompt }); + this.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: MAIN_AGENT_ID, + sessionId: this.session.sessionId, + patch: { lastPrompt }, + }, + }); + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationUndoService, + AgentConversationUndoService, + ScopeActivation.OnScopeCreated, + 'undo', +); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 023fe5aba8..c78cd2f213 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -453,6 +453,8 @@ export * from '#/agent/contextMemory/contextMemory'; export * from '#/agent/contextMemory/contextMemoryService'; export * from '#/agent/contextMemory/contextOps'; export * from '#/agent/contextMemory/compactionHandoff'; +export * from '#/agent/contextMemory/conversationUndoParticipants'; +export * from '#/agent/contextMemory/conversationTime'; export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; export * from '#/agent/contextMemory/messageProjection'; @@ -520,6 +522,8 @@ import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; export * from '#/agent/replayBuilder/types'; +export * from '#/agent/undo/undo'; +export * from '#/agent/undo/undoService'; export * from '#/agent/shellCommand/shellCommand'; export * from '#/agent/shellCommand/shellCommandService'; export * from '#/agent/rpc/rpc'; diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 0752bfae06..d05a5a523c 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -1,20 +1,12 @@ /** * `todo` domain (L4) — `ISessionTodoService` implementation. * - * Holds the session's shared todo list as a stateless facade over the main - * agent's `TodoModel`: `getTodos` reads `wire.getModel(TodoModel)` live, and - * every mutation only dispatches a `tools.update_store` Op to the main agent's - * wire (the single source of truth and replayable timeline), then emits - * `onDidChange` from the rebuilt Model. The service keeps no list copy of its - * own, so the live view and the post-replay view can never drift. Binds the - * `TodoListTool` and the stale-todo reminder into every agent (`onDidCreate`), - * borrowing each agent's services through its `IAgentScopeHandle.accessor`. - * Per-agent bindings are disposed when the agent is disposed. Bound at + * Provides session-wide todo access through the main agent's `wire`, binds + * todo capabilities into each agent, and publishes changes through its typed + * event. The main agent's wire owns the replayable state (including the + * undo-checkpointed `TodoModel`); this facade keeps no list copy of its own + * and there is deliberately no second session-level wire aggregate. Bound at * Session scope. - * - * The session owns the todo facade and tool bindings, while the main Agent wire - * owns the replayable state. This is an explicit cross-scope orchestration - * boundary: there is no second session-level wire aggregate or journal. */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; @@ -29,6 +21,7 @@ import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { IEventBus } from '#/app/event/eventBus'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { IWireService } from '#/wire/wire'; @@ -46,6 +39,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic readonly onDidChange = this.onDidChangeEmitter.event; private readonly agentBindings = new Map(); + private lastKnownTodos: readonly TodoItem[] = []; constructor( @IAgentLifecycleService private readonly agentLifecycle: IAgentLifecycleService, @@ -77,7 +71,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic getTodos(): readonly TodoItem[] { const main = this.agentLifecycle.get(MAIN_AGENT_ID); if (main === undefined) return []; - return main.accessor.get(IWireService).getModel(TodoModel); + return main.accessor.get(IWireService).getModel(TodoModel).current; } setTodos(todos: readonly TodoItem[]): void { @@ -97,7 +91,9 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic if (main === undefined) return; const wire = main.accessor.get(IWireService); wire.dispatch(todoSet({ key: 'todo', value: todos })); - this.onDidChangeEmitter.fire(wire.getModel(TodoModel)); + const current = wire.getModel(TodoModel).current; + this.lastKnownTodos = current; + this.onDidChangeEmitter.fire(current); } private bindAgent(handle: IAgentScopeHandle): void { @@ -106,6 +102,18 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic handle.id, injector.register(TODO_LIST_REMINDER_VARIANT, () => this.staleReminder(handle)), ); + if (handle.id !== MAIN_AGENT_ID) return; + + this.lastKnownTodos = handle.accessor.get(IWireService).getModel(TodoModel).current; + this.trackAgentBinding( + handle.id, + handle.accessor.get(IEventBus).subscribe('context.undone', () => { + const current = handle.accessor.get(IWireService).getModel(TodoModel).current; + if (todoItemsEqual(current, this.lastKnownTodos)) return; + this.lastKnownTodos = current; + this.onDidChangeEmitter.fire(current); + }), + ); } private staleReminder(handle: IAgentScopeHandle): string | undefined { @@ -134,9 +142,17 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic disposable.dispose(); } this.agentBindings.delete(agentId); + if (agentId === MAIN_AGENT_ID) this.lastKnownTodos = []; } } +function todoItemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean { + return ( + a.length === b.length && + a.every((item, index) => item.title === b[index]?.title && item.status === b[index]?.status) + ); +} + registerScopedService( LifecycleScope.Session, ISessionTodoService, diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 29a931aa4b..cf036d029d 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -1,31 +1,23 @@ /** - * `todo` domain (L4) — wire Model (`TodoModel`) and the `tools.update_store` - * Op (`todoSet`) for the session's shared todo list. + * `todo` domain (L4) — persists the session's shared todo document. * - * Declares the todo list as `readonly TodoItem[]` (initial `[]`). The - * persisted record is v1's `tools.update_store` (`{ key: 'todo', value }`), so - * the on-disk vocabulary stays exactly v1's and `wire.replay` — of both v2 and - * v1 sessions — rebuilds the Model from the shared append log. `apply` is the - * single log→model boundary: it ignores non-`todo` keys and sanitizes the - * value through `readTodoItems`, so every consumer (`getTodos`, the tool - * render, the stale reminder, the compaction summary) can trust the Model - * without re-validating. Consumed cross-scope by the Session-scope - * `SessionTodoService`: it dispatches to the MAIN agent's wire (the single - * source of truth and replayable timeline), and `getTodos` reads the rebuilt - * Model back from that same wire after restore. The Ops register into the - * global `OP_REGISTRY` at import time, so they are in place before the main - * agent restores. + * Validates todo state through the local item contract, keeps it aligned with + * conversation undo through `contextMemory`, and serves the Session-scope todo + * facade from the main agent's wire. */ import { z } from 'zod'; -import { defineModel } from '#/wire/model'; +import { + defineCheckpointedModel, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; import { readTodoItems, type TodoItem } from './todoItem'; -export type TodoModelState = readonly TodoItem[]; +export type TodoModelState = Checkpointed; -export const TodoModel = defineModel('todo', () => []); +export const TodoModel = defineCheckpointedModel('todo', (): readonly TodoItem[] => []); declare module '#/wire/types' { interface PersistedOpMap { @@ -35,5 +27,6 @@ declare module '#/wire/types' { export const todoSet = TodoModel.defineOp('tools.update_store', { schema: z.object({ key: z.string(), value: z.unknown() }), - apply: (s, p) => (p.key === 'todo' ? readTodoItems(p.value) : s), + apply: (s, p) => + p.key === 'todo' ? { ...s, current: readTodoItems(p.value) } : s, }); diff --git a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts index 2189dac79b..3178f59ebe 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -2,6 +2,7 @@ import type { Message } from '#/kosong/contract/message'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { buildImageCompressionCaption } from '#/agent/media/image-compress'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IWireService } from '#/wire/wire'; import { @@ -579,12 +580,12 @@ describe('Agent context', () => { expect(contextSize.get()).toEqual({ size: 0, measured: 0, estimated: 0 }); }); - it('rebases the measured prefix to an estimate when undo truncates it', () => { - ctx.appendAssistantTextWithUsage(1, 'a1', 1_000); - ctx.appendAssistantTextWithUsage(2, 'a2', 2_000); + it('rebases the measured prefix to an estimate when undo truncates it', async () => { + ctx.appendTurnExchange('u1', 'a1', 1_000); + ctx.appendTurnExchange('u2', 'a2', 2_000); expect(contextSize.get().measured).toBe(2_000); - ctx.undoHistory(1); + await ctx.undoHistory(1); const surviving = context.get(); expect(surviving.map((m) => m.role)).toEqual(['user', 'assistant']); @@ -592,20 +593,20 @@ describe('Agent context', () => { expect(contextSize.get()).toEqual({ size: estimate, measured: estimate, estimated: 0 }); }); - it('keeps the measured prefix when undo removes only the unmeasured tail', () => { - ctx.appendAssistantTextWithUsage(1, 'a1', 1_000); - ctx.appendUserMessage([{ type: 'text', text: 'unmeasured follow up' }]); + it('keeps the measured prefix when undo removes only the unmeasured tail', async () => { + ctx.appendTurnExchange('u1', 'a1', 1_000); + ctx.appendTurnExchange('u2', 'a2'); expect(contextSize.get().measured).toBe(1_000); - ctx.undoHistory(1); + await ctx.undoHistory(1); expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']); expect(contextSize.get()).toEqual({ size: 1_000, measured: 1_000, estimated: 0 }); }); - it('undo only counts real user prompts, skipping task notifications', () => { - ctx.appendAssistantText(1, 'first response'); - ctx.appendAssistantText(2, 'second response'); + it('undo only counts real user prompts, skipping task notifications', async () => { + ctx.appendTurnExchange('u1', 'first response'); + ctx.appendTurnExchange('u2', 'second response'); context.append( { @@ -629,14 +630,14 @@ describe('Agent context', () => { 'user', ]); - ctx.undoHistory(1); + await ctx.undoHistory(1); expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); - it('removes injection messages inside the undone turn', () => { - context.append(userMessage('earlier question', { kind: 'user' })); - context.append(userMessage('do the work', { kind: 'user' })); + it('removes injection messages inside the undone turn', async () => { + ctx.appendUserTurn('earlier question'); + ctx.appendUserTurn('do the work'); context.append( userMessage('Plan mode is active', { kind: 'injection', @@ -652,7 +653,7 @@ describe('Agent context', () => { }, ); - ctx.undoHistory(1); + await ctx.undoHistory(1); expect(context.get()).toEqual([ expect.objectContaining({ @@ -663,6 +664,30 @@ describe('Agent context', () => { ]); }); + it('removes a pre-anchor image compression reminder when undoing its prompt', async () => { + profile.update({ activeToolNames: [] }); + const caption = buildImageCompressionCaption({ + original: { width: 3264, height: 666, byteLength: 344 * 1024, mimeType: 'image/png' }, + final: { width: 2000, height: 408, byteLength: 282 * 1024, mimeType: 'image/png' }, + originalPath: '/tmp/originals/shot.png', + }); + ctx.mockNextResponse({ type: 'text', text: 'done' }); + await ctx.rpc.prompt({ + input: [{ type: 'text', text: `inspect this image ${caption}` }], + }); + await ctx.untilTurnEnd(); + + expect(context.get()).toMatchObject([ + { origin: { kind: 'injection', variant: 'image_compression' } }, + { origin: { kind: 'user' } }, + { role: 'assistant' }, + ]); + + await ctx.undoHistory(1); + + expect(context.get()).toEqual([]); + }); + describe('notification projection', () => { it('does not merge a cron-fire envelope into an adjacent user message', () => { const cronEnvelope = diff --git a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts index 710c136b28..c016a62bdb 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -172,6 +172,25 @@ describe('reduceContextTranscript', () => { expect(texts(result)).toEqual(['message A', 'reply A']); }); + it('removes a pre-anchor image compression reminder owned by the undone prompt', () => { + const result = reduceContextTranscript([ + appendMessage( + userMessage('compressed image', { + kind: 'injection', + variant: 'image_compression', + ownerPromptId: 'prompt-1', + }), + ), + appendMessage({ ...userMessage('undo me', { kind: 'user' }), id: 'prompt-1' }), + appendMessage(assistantMessage('undone answer')), + undo(1), + appendMessage(userMessage('keep me', { kind: 'user' })), + appendMessage(assistantMessage('kept answer')), + ]); + + expect(texts(result)).toEqual(['keep me', 'kept answer']); + }); + it('undo stops at a compaction summary', () => { const result = reduceContextTranscript([ appendMessage(userMessage('old')), diff --git a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts index 61b29e4b01..acbe72909f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { precheckUndo } from '#/agent/contextMemory/contextOps'; +import { + computeUndoCut, + contextUndo, + isFullyUndoable, +} from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; function text(value: string): { type: 'text'; text: string } { @@ -40,63 +44,82 @@ function compaction(): ContextMessage { const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; -describe('precheckUndo', () => { - it('returns ok when enough real user prompts exist', () => { - expect(precheckUndo([user(USER_ORIGIN), assistant()], 1)).toEqual({ ok: true }); +describe('computeUndoCut', () => { + it('finds the cut for the last real user prompt', () => { + const cut = computeUndoCut([user(USER_ORIGIN), assistant()], 1); + expect(cut).toEqual({ cutIndex: 0, removedCount: 1, stoppedAtCompaction: false }); + expect(isFullyUndoable(cut, 1)).toBe(true); }); it('skips trailing non-user messages while scanning', () => { - expect(precheckUndo([user(USER_ORIGIN), assistant(), assistant()], 1)).toEqual({ ok: true }); + const cut = computeUndoCut([user(USER_ORIGIN), assistant(), assistant()], 1); + expect(cut.cutIndex).toBe(0); + expect(isFullyUndoable(cut, 1)).toBe(true); }); it('treats a user message without origin as a real prompt (legacy)', () => { - expect(precheckUndo([user(), assistant()], 1)).toEqual({ ok: true }); + const cut = computeUndoCut([user(), assistant()], 1); + expect(cut.cutIndex).toBe(0); + expect(isFullyUndoable(cut, 1)).toBe(true); }); - it('returns empty when the history has no real user prompt', () => { - expect(precheckUndo([], 1)).toEqual({ - ok: false, - reason: 'empty', - requested: 1, - undoable: 0, - }); + it('finds nothing when the history has no real user prompt', () => { + const cut = computeUndoCut([], 1); + expect(cut).toEqual({ cutIndex: -1, removedCount: 0, stoppedAtCompaction: false }); + expect(isFullyUndoable(cut, 1)).toBe(false); }); - it('returns empty when only injections are present', () => { - expect(precheckUndo([injection(), assistant()], 1)).toEqual({ - ok: false, - reason: 'empty', - requested: 1, - undoable: 0, - }); + it('skips injections without counting them', () => { + const cut = computeUndoCut([injection(), assistant()], 1); + expect(cut.cutIndex).toBe(-1); + expect(isFullyUndoable(cut, 1)).toBe(false); }); - it('returns insufficient when some but fewer than count prompts exist', () => { + it('counts fewer prompts than requested as not fully undoable', () => { const history = [user(USER_ORIGIN), assistant(), user(USER_ORIGIN), assistant()]; - expect(precheckUndo(history, 3)).toEqual({ - ok: false, - reason: 'insufficient', - requested: 3, - undoable: 2, - }); + const cut = computeUndoCut(history, 3); + expect(cut.removedCount).toBe(2); + expect(isFullyUndoable(cut, 3)).toBe(false); }); - it('returns compaction_boundary when a summary is hit before count is met', () => { - expect(precheckUndo([user(USER_ORIGIN), compaction(), assistant()], 1)).toEqual({ - ok: false, - reason: 'compaction_boundary', - requested: 1, - undoable: 0, - }); + it('stops at a compaction summary', () => { + const cut = computeUndoCut([user(USER_ORIGIN), compaction(), assistant()], 1); + expect(cut).toEqual({ cutIndex: -1, removedCount: 0, stoppedAtCompaction: true }); + expect(isFullyUndoable(cut, 1)).toBe(false); }); - it('reports compaction_boundary over insufficient when the boundary stops the scan', () => { + it('stops at a compaction summary even after counting some prompts', () => { const history = [user(USER_ORIGIN), compaction(), user(USER_ORIGIN), assistant()]; - expect(precheckUndo(history, 2)).toEqual({ - ok: false, - reason: 'compaction_boundary', - requested: 2, - undoable: 1, - }); + const cut = computeUndoCut(history, 2); + expect(cut.removedCount).toBe(1); + expect(cut.stoppedAtCompaction).toBe(true); + expect(isFullyUndoable(cut, 2)).toBe(false); }); }); + +describe('contextUndo op', () => { + it('slices the history at the cut point, dropping post-cut injections too', () => { + const state = [ + user(USER_ORIGIN), + assistant(), + user(USER_ORIGIN), + injection(), + assistant(), + ]; + const next = contextUndo.apply(state, { count: 1 }); + expect(next).toEqual([user(USER_ORIGIN), assistant()]); + }); + + it('returns the same reference when not fully undoable', () => { + const state = [user(USER_ORIGIN), compaction(), assistant()]; + expect(contextUndo.apply(state, { count: 1 })).toBe(state); + }); + + it.each([0, 0.5, Number.MAX_SAFE_INTEGER + 1])( + 'returns the same reference for invalid count %s', + (count) => { + const state = [user(USER_ORIGIN), assistant()]; + expect(contextUndo.apply(state, { count })).toBe(state); + }, + ); +}); diff --git a/packages/agent-core-v2/test/agent/loop/loop.test.ts b/packages/agent-core-v2/test/agent/loop/loop.test.ts index 67d0e2393d..02a7a24767 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -529,6 +529,65 @@ describe('Agent loop', () => { expect(ctx.llmCalls).toHaveLength(3); }); + it('refuses a quiescence lease while a turn is active without cancelling it', async () => { + let started!: () => void; + const activeStarted = new Promise((resolve) => { + started = resolve; + }); + let release!: () => void; + const canFinish = new Promise((resolve) => { + release = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-quiescence', async (_hookCtx, next) => { + started(); + await canFinish; + await next(); + }); + + const active = (await loop.enqueue(nextTurnMessage('active')).assigned).turn; + await activeStarted; + + expect(loop.tryAcquireQuiescence()).toBeUndefined(); + expect(active.signal.aborted).toBe(false); + + hook.dispose(); + ctx.mockNextResponse({ type: 'text', text: 'completed normally' }); + release(); + await expect(active.result).resolves.toMatchObject({ type: 'completed' }); + }); + + it('holds new admissions until an idle quiescence lease is released', async () => { + const lease = loop.tryAcquireQuiescence(); + expect(lease).toBeDefined(); + const held = loop.enqueue(nextTurnMessage('held')); + let assigned = false; + void held.assigned.then(() => { + assigned = true; + }); + + await Promise.resolve(); + expect(assigned).toBe(false); + expect(loop.status()).toMatchObject({ state: 'idle', hasPendingRequests: true }); + + ctx.mockNextResponse({ type: 'text', text: 'after undo' }); + lease?.dispose(); + const resumed = (await held.assigned).turn; + await expect(resumed.result).resolves.toMatchObject({ type: 'completed' }); + }); + + it('can abort an admission while quiescence holds it', async () => { + const lease = loop.tryAcquireQuiescence(); + expect(lease).toBeDefined(); + const held = loop.enqueue(nextTurnMessage('held')); + + expect(held.abort()).toBe(true); + await expect(held.assigned).rejects.toBeDefined(); + expect(loop.hasPendingRequests()).toBe(false); + + lease?.dispose(); + expect(loop.status().state).toBe('idle'); + }); + it('cancels a running step without cancelling its turn and continues the next step', async () => { let releaseRunning!: () => void; const running = new Promise((resolve) => { diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 8ea6a4dc2f..8a14046f23 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -72,6 +72,7 @@ export function stubLoopWithHooks(options: StubLoopOptions = {}): StubLoop { async run() { return { type: 'completed', steps: 0, truncated: false }; }, status() { return { state: active !== undefined ? 'running' : 'idle', activeTurnId: active?.id, pendingTurnIds: [], hasPendingRequests: queue.hasPendingRequests() }; }, cancel(turnId, reason) { cancels.push({ turnId, reason }); if (active === undefined || (turnId !== undefined && active.id !== turnId)) return false; active.cancel(reason); return true; }, + tryAcquireQuiescence: () => toDisposable(() => {}), hasPendingRequests: () => queue.hasPendingRequests(), registerLoopErrorHandler: errorHandlers.register, settled: () => Promise.resolve(), drainNextBatch(context) { const batch = queue.takeNextBatch(); if (!batch) return undefined; materialize(batch.driver, context); for (const r of batch.merged) materialize(r, context); return batch; }, diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index da6623abf9..8977ccdc6e 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -947,11 +947,11 @@ describe('Plan service', () => { it('keeps the preserved injection index aligned after undo removes earlier messages', async () => { await plan.enter('test-plan', false); - ctx.appendUserMessage([{ type: 'text', text: 'draft the plan' }]); + ctx.appendUserTurn('draft the plan'); await injectDynamic(); ctx.appendAssistantTurn(1, 'Plan drafted.'); - ctx.undoHistory(1); + await ctx.undoHistory(1); ctx.appendUserMessage([{ type: 'text', text: 'new plan request' }]); await injectDynamic(); diff --git a/packages/agent-core-v2/test/agent/plan/planOps.test.ts b/packages/agent-core-v2/test/agent/plan/planOps.test.ts index c8b8900dca..f3fe745f85 100644 --- a/packages/agent-core-v2/test/agent/plan/planOps.test.ts +++ b/packages/agent-core-v2/test/agent/plan/planOps.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; +import { contextAppendMessage, contextUndo } from '#/agent/contextMemory/contextOps'; import { IEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import { @@ -60,20 +61,20 @@ async function readRecords(key = KEY): Promise { describe('plan ops (wire-backed)', () => { it('enter/cancel/exit drive active state and persist flat records', async () => { - expect(wire.getModel(PlanModel).active).toBe(false); + expect(wire.getModel(PlanModel).current.active).toBe(false); wire.dispatch(planModeEnter({ id: 'p1' })); - expect(wire.getModel(PlanModel)).toEqual({ + expect(wire.getModel(PlanModel).current).toEqual({ active: true, id: 'p1', }); wire.dispatch(planModeCancel({ id: 'p1' })); - expect(wire.getModel(PlanModel)).toEqual({ active: false }); + expect(wire.getModel(PlanModel).current).toEqual({ active: false }); wire.dispatch(planModeEnter({ id: 'p2' })); wire.dispatch(planModeExit({})); - expect(wire.getModel(PlanModel).active).toBe(false); + expect(wire.getModel(PlanModel).current.active).toBe(false); const records = await readRecords(); expect(records.map((record) => record.type)).toEqual([ @@ -94,11 +95,11 @@ describe('plan ops (wire-backed)', () => { it('cancel and exit both deactivate plan mode but emit distinct record types', async () => { wire.dispatch(planModeEnter({ id: 'p1' })); wire.dispatch(planModeCancel({ id: 'p1' })); - expect(wire.getModel(PlanModel)).toEqual({ active: false }); + expect(wire.getModel(PlanModel).current).toEqual({ active: false }); wire.dispatch(planModeEnter({ id: 'p2' })); wire.dispatch(planModeExit({ id: 'p2' })); - expect(wire.getModel(PlanModel)).toEqual({ active: false }); + expect(wire.getModel(PlanModel).current).toEqual({ active: false }); const records = await readRecords(); expect(records.map((record) => record.type)).toEqual([ @@ -122,6 +123,25 @@ describe('plan ops (wire-backed)', () => { expect(wire.getModel(PlanModel)).toBe(active); }); + it('ignores an invalid undo count without corrupting checkpoint state', () => { + wire.dispatch( + contextAppendMessage({ + message: { + role: 'user', + content: [{ type: 'text', text: 'keep me' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }), + ); + const checkpointed = wire.getModel(PlanModel); + + wire.dispatch(contextUndo({ count: 0.5 })); + + expect(wire.getModel(PlanModel)).toBe(checkpointed); + expect(wire.getModel(PlanModel).current).toEqual({ active: false }); + }); + it('replay rebuilds active state silently', async () => { wire.dispatch(planModeEnter({ id: 'p1' })); const records = await readRecords(); @@ -137,7 +157,7 @@ describe('plan ops (wire-backed)', () => { testWireScope(SCOPE, 'plan-replay'), records, ); - expect(host.wire.getModel(PlanModel)).toEqual({ + expect(host.wire.getModel(PlanModel).current).toEqual({ active: true, id: 'p1', }); @@ -153,7 +173,7 @@ describe('plan ops (wire-backed)', () => { { type: 'plan_mode.cancel', id: 'p1' }, ], ); - expect(cancelled.wire.getModel(PlanModel).active).toBe(false); + expect(cancelled.wire.getModel(PlanModel).current.active).toBe(false); }); it('plan.revision persists a flat reference record and advances the per-id counter', async () => { @@ -167,7 +187,7 @@ describe('plan ops (wire-backed)', () => { bytes: 12, }), ); - expect(wire.getModel(PlanModel)).toEqual({ + expect(wire.getModel(PlanModel).current).toEqual({ active: true, id: 'p1', revisionCount: { p1: 1 }, @@ -182,7 +202,7 @@ describe('plan ops (wire-backed)', () => { bytes: 20, }), ); - expect(wire.getModel(PlanModel).revisionCount).toEqual({ p1: 2 }); + expect(wire.getModel(PlanModel).current.revisionCount).toEqual({ p1: 2 }); const records = await readRecords(); expect(records.map((record) => record.type)).toEqual([ @@ -222,7 +242,7 @@ describe('plan ops (wire-backed)', () => { }), ); host.wire.dispatch(planModeExit({})); - expect(host.wire.getModel(PlanModel)).toEqual({ + expect(host.wire.getModel(PlanModel).current).toEqual({ active: false, revisionCount: { p1: 1 }, }); @@ -230,7 +250,7 @@ describe('plan ops (wire-backed)', () => { // Re-entering the same plan id continues the counter instead of // restarting it, so later revisions never overwrite earlier blobs. host.wire.dispatch(planModeEnter({ id: 'p1' })); - expect(host.wire.getModel(PlanModel).revisionCount).toEqual({ p1: 1 }); + expect(host.wire.getModel(PlanModel).current.revisionCount).toEqual({ p1: 1 }); expect( emissions.filter((e) => (e as { type: string }).type === 'plan.revision'), @@ -279,7 +299,7 @@ describe('plan ops (wire-backed)', () => { testWireScope(SCOPE, 'plan-revision-replay'), records, ); - expect(host.wire.getModel(PlanModel)).toEqual({ + expect(host.wire.getModel(PlanModel).current).toEqual({ active: true, id: 'p1', revisionCount: { p1: 2 }, diff --git a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts index a607af735d..3c586f7b2b 100644 --- a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts @@ -1,5 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { ErrorCodes } from '#/errors'; + import { createTestAgent, telemetryServices, @@ -22,7 +24,7 @@ describe('undoHistory RPC', () => { it('tracks conversation_undo after undoing history', async () => { records = []; ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); - ctx.appendUserMessage([{ type: 'text', text: 'undo me' }]); + ctx.appendUserTurn('undo me'); const undone = await ctx.rpc.undoHistory({ count: 1 }); @@ -32,4 +34,19 @@ describe('undoHistory RPC', () => { properties: { agent_id: 'main', count: 1 }, }); }); + + it('rejects a fractional count without changing persisted history', async () => { + records = []; + ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); + ctx.appendUserTurn('keep me'); + const history = ctx.context.get(); + + await expect(ctx.rpc.undoHistory({ count: 0.5 })).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + details: { field: 'count' }, + }); + + expect(ctx.context.get()).toBe(history); + expect(records).not.toContainEqual(expect.objectContaining({ event: 'conversation_undo' })); + }); }); diff --git a/packages/agent-core-v2/test/agent/skill/skill.test.ts b/packages/agent-core-v2/test/agent/skill/skill.test.ts index 1aeee70249..a46bd364d9 100644 --- a/packages/agent-core-v2/test/agent/skill/skill.test.ts +++ b/packages/agent-core-v2/test/agent/skill/skill.test.ts @@ -69,7 +69,6 @@ describe('AgentSkillService', () => { reg.definePartialInstance(IAgentPromptService, { enqueue: ({ message }: { message: ContextMessage }) => { prompted.push(message); return Promise.resolve({ launched: Promise.resolve(fakeTurn()) } as never); }, retry: () => Promise.resolve(undefined), - undo: () => 0, clear: () => {}, }); registerTestAgentWireServices(reg, 'wire/skill-test'); @@ -162,7 +161,6 @@ describe('SkillTool', () => { reg.definePartialInstance(IAgentPromptService, { enqueue: ({ message }: { message: ContextMessage }) => { prompted.push(message); return Promise.resolve({ launched: Promise.resolve(fakeTurn()) } as never); }, retry: () => Promise.resolve(undefined), - undo: () => 0, clear: () => {}, }); registerTestAgentWireServices(reg, 'wire/skill-test'); diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 21c876ad1a..628d9bd7cb 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -25,6 +25,9 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IEventBus } from '#/app/event/eventBus'; import type { IExternalHooksRunnerService } from '#/app/externalHooksRunner/externalHooksRunner'; import { IAgentLoopService } from '#/agent/loop/loop'; +import { MessageStepRequest } from '#/agent/loop/stepRequest'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; +import { ErrorCodes } from '#/errors'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { configServices, @@ -714,6 +717,109 @@ describe('AgentTaskService — notification delivery', () => { } }); + it('re-delivers a terminal task notification removed by undo when output is unavailable', async () => { + const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-undo-')); + let fixture: TaskServiceFixture | undefined; + try { + const persistence = createAgentTaskPersistence(sessionDir); + await persistence.writeTask(persistedAgent()); + await persistence.appendTaskOutput('agent-done0000', 'restored subagent summary'); + fixture = createAgentTaskService({ sessionDir }); + const { agent, ctx, manager } = fixture; + ctx.appendUserTurn('start the background task'); + agent.context.appendUserMessage.mockClear(); + + await manager.loadFromDisk(); + await manager.reconcile(); + await vi.waitFor(() => { + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); + }); + vi.spyOn(manager, 'getOutputSnapshot').mockRejectedValueOnce( + new Error('output unavailable'), + ); + + await ctx.get(IAgentConversationUndoService).undo(1); + + expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(2); + expect(ctx.context.get().some((message) => message.origin?.kind === 'user')).toBe(false); + expect( + ctx.context.get().filter((message) => message.origin?.kind === 'task'), + ).toHaveLength(1); + } finally { + await cleanupSessionDir(sessionDir, fixture); + } + }); + + it('preserves a queued notification when undo rejects an active turn', async () => { + const fixture = createAgentTaskService(); + const { ctx, manager } = fixture; + const loop = ctx.get(IAgentLoopService); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + let release!: () => void; + const canFinish = new Promise((resolve) => { + release = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-notification-undo', async (_hookCtx, next) => { + markStarted(); + await canFinish; + await next(); + }); + + try { + ctx.appendTurnExchange('kept prompt', 'kept answer'); + const active = ( + await loop.enqueue( + new MessageStepRequest( + { + role: 'user', + content: [{ type: 'text', text: 'remove me' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { admission: 'newTurn' }, + ), + ).assigned + ).turn; + await started; + const taskId = registerProcess(manager, immediateProcess(0, 'done'), 'echo done', 'done'); + await vi.waitFor(() => { + expect(manager.getTask(taskId)?.status).toBe('completed'); + expect(loop.hasPendingRequests()).toBe(true); + }); + expect(notifiedCount(ctx)).toBe(0); + + await expect(ctx.get(IAgentConversationUndoService).undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_BUSY, + details: { reason: 'loop' }, + }); + expect(active.signal.aborted).toBe(false); + expect( + ctx.context.get().filter((message) => message.origin?.kind === 'task'), + ).toEqual([]); + + ctx.mockNextResponse({ type: 'text', text: 'notification acknowledged' }); + ctx.mockNextResponse({ type: 'text', text: 'turn completed' }); + release(); + await expect(active.result).resolves.toMatchObject({ type: 'completed' }); + expect( + ctx.context.get().filter((message) => message.origin?.kind === 'task'), + ).toEqual([ + expect.objectContaining({ + origin: expect.objectContaining({ taskId, status: 'completed' }), + }), + ]); + expect(notifiedCount(ctx)).toBe(1); + } finally { + release(); + hook.dispose(); + await ctx.get(ISessionMetadata).ready; + await ctx.dispose(); + } + }); + it('does not double-notify newly lost restored agent tasks', async () => { const sessionDir = await mkdtemp(join(tmpdir(), 'kimi-bg-agent-lost-')); let fixture: TaskServiceFixture | undefined; diff --git a/packages/agent-core-v2/test/agent/task/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index 1b2e8f49bd..d7e3f49319 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -13,7 +13,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore, toDisposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; import { TestInstantiationService } from '#/_base/di/test'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; import { IAgentContextInjectorService, type ContextInjectionContext, @@ -47,6 +49,7 @@ import { EventBusService } from '#/app/event/eventBusService'; import { ITaskService } from '#/app/task/task'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; +import { stubLog } from '../../_base/log/stubs'; import { stubContextMemory } from '../contextMemory/stubs'; import { stubLoopWithHooks } from '../loop/stubs'; import type { TaskServiceTestManager } from './stubs'; @@ -89,6 +92,11 @@ describe('AgentTaskService', () => { ix = disposables.add(new TestInstantiationService()); eventBus = disposables.add(new EventBusService()); injectionProviders = new Map(); + ix.stub(ILogService, stubLog()); + ix.stub(IAgentConversationUndoParticipantRegistry, { + register: () => toDisposable(() => {}), + list: () => [], + }); ix.stub(IWireService, stubWireService()); ix.stub(IEventBus, eventBus); ix.stub(IAgentContextInjectorService, { @@ -461,6 +469,11 @@ describe('AgentTaskService', () => { captureRestoreHook?: (hook: RestoreHook) => void, ): TestInstantiationService { const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IAgentConversationUndoParticipantRegistry, { + register: () => toDisposable(() => {}), + list: () => [], + }); ix.stub(IWireService, stubWireService(captureRestoreHook)); ix.stub(IEventBus, disposables.add(new EventBusService())); ix.stub(IAgentContextInjectorService, { diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts index a74ac91146..69f151edb0 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelect.e2e.test.ts @@ -21,6 +21,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -235,7 +236,7 @@ describe('progressive tool disclosure end-to-end', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] }); await ctx.untilTurnEnd(); - ctx.get(IAgentContextMemoryService).undo(1); + await ctx.get(IAgentConversationUndoService).undo(1); const afterUndo = ctx.get(IAgentContextMemoryService).get(); expect(afterUndo.some((message) => message.tools?.some((tool) => tool.name === MCP_ALPHA))).toBe( false, diff --git a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts index 881d9c1fbf..0258f7b2ef 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -230,6 +230,10 @@ class FakeLoopService implements IAgentLoopService { throw new Error('unused in this suite'); } + tryAcquireQuiescence(): IDisposable | undefined { + return toDisposable(() => {}); + } + hasPendingRequests(): boolean { return false; } diff --git a/packages/agent-core-v2/test/agent/undo/undo.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts new file mode 100644 index 0000000000..170c3b63c6 --- /dev/null +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -0,0 +1,509 @@ +/** + * Scenario: undo validation and restoration across conversation-scoped models. + * Responsibility: AgentConversationUndoService commits one undo and publishes + * restored observable state. + * Wiring: full TestAgentContext with real wire models and event bus. + * Run: pnpm --filter @moonshot-ai/agent-core-v2 test -- test/agent/undo/undo.test.ts + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationUndoParticipantRegistry } from '#/agent/contextMemory/conversationUndoParticipants'; +import { contextApplyCompaction } from '#/agent/contextMemory/contextOps'; +import { + CHECKPOINTED_MODELS, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { MessageStepRequest } from '#/agent/loop/stepRequest'; +import { TurnModel } from '#/agent/loop/turnOps'; +import { IAgentPlanService } from '#/agent/plan/plan'; +import { PlanModel } from '#/agent/plan/planOps'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; +import { IEventBus } from '#/app/event/eventBus'; +import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; +import { ErrorCodes } from '#/errors'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { TodoModel, todoSet } from '#/session/todo/todoOps'; +import { defineModel } from '#/wire/model'; +import { IWireService } from '#/wire/wire'; + +import { createTestAgent, telemetryServices, type TestAgentContext } from '../../harness'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; + +describe('AgentConversationUndoService', () => { + let ctx: TestAgentContext; + let records: TelemetryRecord[]; + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + function setup() { + records = []; + ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); + ctx.get(IAgentContextMemoryService); + return ctx; + } + + it('exposes availability from context history', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + expect(undo.availability()).toEqual({ maxTurns: 0, stoppedAtCompaction: false }); + + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + expect(undo.availability()).toEqual({ maxTurns: 2, stoppedAtCompaction: false }); + }); + + it('rejects undo with structured reasons', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + + await expect(undo.undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'empty', requestedCount: 1, undoableCount: 0 }, + }); + + ctx.appendTurnExchange('u1', 'a1'); + await expect(undo.undo(2)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'insufficient', requestedCount: 2, undoableCount: 1 }, + }); + }); + + it.each([ + 0, + -1, + 0.5, + Number.MAX_SAFE_INTEGER + 1, + Number.POSITIVE_INFINITY, + Number.NaN, + ])('rejects invalid undo count %s without mutating history', async (count) => { + setup(); + ctx.appendTurnExchange('u1', 'a1'); + const history = ctx.context.get(); + + await expect(ctx.get(IAgentConversationUndoService).undo(count)).rejects.toMatchObject({ + code: ErrorCodes.REQUEST_INVALID, + details: { field: 'count' }, + }); + + expect(ctx.context.get()).toBe(history); + }); + + it('returns session.busy for an active turn without cancelling it', async () => { + setup(); + const loop = ctx.get(IAgentLoopService); + let started!: () => void; + let release!: () => void; + const didStart = new Promise((resolve) => { + started = resolve; + }); + const canFinish = new Promise((resolve) => { + release = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-invalid-undo', async (_hookCtx, next) => { + started(); + await canFinish; + await next(); + }); + ctx.mockNextResponse({ type: 'text', text: 'system result' }); + const turn = ( + await loop.enqueue( + new MessageStepRequest( + { + role: 'user', + content: [{ type: 'text', text: 'system work' }], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'test' }, + }, + { admission: 'newTurn' }, + ), + ).assigned + ).turn; + await didStart; + const history = ctx.context.get(); + + await expect(ctx.get(IAgentConversationUndoService).undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_BUSY, + details: { reason: 'loop' }, + }); + expect(turn.signal.aborted).toBe(false); + expect(loop.status().state).toBe('running'); + expect(ctx.context.get()).toBe(history); + + hook.dispose(); + release(); + await expect(turn.result).resolves.toMatchObject({ type: 'completed' }); + }); + + it('returns session.busy for active compaction without cancelling it', async () => { + setup(); + ctx.appendTurnExchange('u1', 'a1'); + const history = ctx.context.get(); + const compaction = ctx.get(IAgentFullCompactionService); + const abortController = new AbortController(); + const active = vi.spyOn(compaction, 'compacting', 'get').mockReturnValue({ + abortController, + promise: new Promise(() => {}), + trigger: 'manual', + tokenCount: 2, + }); + + try { + await expect(ctx.get(IAgentConversationUndoService).undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_BUSY, + details: { reason: 'compaction' }, + }); + expect(abortController.signal.aborted).toBe(false); + expect(ctx.context.get()).toBe(history); + } finally { + active.mockRestore(); + } + }); + + it('refuses to cross a compaction boundary', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.get(IAgentContextMemoryService).applyCompaction({ + summary: 'summary of u1', + compactedCount: 2, + tokensBefore: 100, + tokensAfter: 10, + }); + ctx.appendTurnExchange('u2', 'a2'); + + expect(undo.availability()).toEqual({ maxTurns: 1, stoppedAtCompaction: true }); + await expect(undo.undo(2)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'compaction_boundary', requestedCount: 2, undoableCount: 1 }, + }); + + await undo.undo(1); + const history = ctx.context.get(); + expect(history.map((m) => m.role)).toEqual(['user', 'user']); + expect(history[1]?.origin?.kind).toBe('compaction_summary'); + }); + + it('refuses loudly when a legacy compaction leaves anchors without checkpoints', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + wire.dispatch(contextApplyCompaction({ summary: 'legacy summary', compactedCount: 2 })); + expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'user', 'assistant']); + + await expect(undo.undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'compaction_boundary', requestedCount: 1, undoableCount: 0 }, + }); + expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'user', 'assistant']); + }); + + it('attributes a checkpoint depth failure to the limiting model', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + ctx.appendTurnExchange('u1', 'a1'); + // A checkpointed model that never tracks anchors (no reducers) drags the + // depth to 0 without any compaction in history. + const defective = defineModel>('testDefective', () => ({ + current: null, + checkpoints: [], + })); + CHECKPOINTED_MODELS.push(defective); + + try { + await expect(undo.undo(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { + reason: 'checkpoint_lost', + requestedCount: 1, + undoableCount: 0, + model: 'testDefective', + }, + }); + } finally { + CHECKPOINTED_MODELS.splice(CHECKPOINTED_MODELS.indexOf(defective), 1); + } + expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'assistant']); + }); + + it('restores todos to their pre-turn value', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'kept', status: 'pending' }] })); + ctx.appendTurnExchange('u2', 'a2'); + wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'doomed', status: 'pending' }] })); + + await undo.undo(1); + + expect(wire.getModel(TodoModel).current).toEqual([{ title: 'kept', status: 'pending' }]); + }); + + it('restores plan mode and its telemetry mirror to their pre-turn value', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + await ctx.get(IAgentPlanService).enter('plan-x', false); + const restoredModes: boolean[] = []; + const subscription = ctx.get(IEventBus).subscribe('agent.status.updated', (event) => { + if (event.planMode !== undefined) restoredModes.push(event.planMode); + }); + + try { + await undo.undo(1); + + expect(wire.getModel(PlanModel).current.active).toBe(false); + expect(ctx.get(IAgentTelemetryContextService).get().mode).toBe('agent'); + expect(restoredModes).toEqual([false]); + } finally { + subscription.dispose(); + } + }); + + it('does not roll back world-time turn bookkeeping', async () => { + setup(); + const undo = ctx.get(IAgentConversationUndoService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); + + await undo.undo(1); + + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); + }); + + it('flushes state reconciliation before publishing undo', async () => { + setup(); + const wire = ctx.get(IWireService); + const order: string[] = []; + const flush = vi.spyOn(wire, 'flush'); + const originalFlush = flush.getMockImplementation(); + flush.mockImplementation(async () => { + order.push('flush'); + await originalFlush?.(); + }); + const participants = ctx.get(IAgentConversationUndoParticipantRegistry); + participants.register({ + id: 'test.state', + reconcileAfterUndo: async () => { + order.push('state'); + }, + }); + const subscription = ctx.get(IEventBus).subscribe('context.undone', () => { + order.push('context.undone'); + }); + ctx.appendTurnExchange('u1', 'a1'); + + try { + await ctx.get(IAgentConversationUndoService).undo(1); + + expect(order).toEqual(['flush', 'state', 'flush', 'context.undone']); + } finally { + subscription.dispose(); + flush.mockRestore(); + } + }); + + it.each([ + [1, []], + [2, ['state']], + ] as const)( + 'rejects the committed undo when post-cut flush %i fails', + async (failureCall, expectedReconciled) => { + setup(); + const wire = ctx.get(IWireService); + const originalFlush = wire.flush.bind(wire); + let flushCalls = 0; + const storageError = new Error('storage unavailable'); + const flush = vi.spyOn(wire, 'flush').mockImplementation(async () => { + flushCalls += 1; + if (flushCalls === failureCall) throw storageError; + await originalFlush(); + }); + const reconciled: string[] = []; + const participants = ctx.get(IAgentConversationUndoParticipantRegistry); + participants.register({ + id: 'test.flush-failure-state', + reconcileAfterUndo: async () => { + reconciled.push('state'); + }, + }); + const undone: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.undone', ({ turns }) => { + undone.push(turns); + }); + ctx.appendTurnExchange('u1', 'a1'); + + try { + await expect(ctx.get(IAgentConversationUndoService).undo(1)).rejects.toBe(storageError); + expect(ctx.context.get()).toEqual([]); + expect(reconciled).toEqual(expectedReconciled); + expect(undone).toEqual([]); + expect(records.filter((record) => record.event === 'conversation_undo')).toEqual([]); + } finally { + subscription.dispose(); + flush.mockRestore(); + } + }, + ); + + it('serializes concurrent undos through state reconciliation', async () => { + setup(); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + let releaseFirst!: () => void; + const firstBlocked = new Promise((resolve) => { + releaseFirst = resolve; + }); + let markFirstStarted!: () => void; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + let calls = 0; + let active = 0; + let maxActive = 0; + ctx.get(IAgentConversationUndoParticipantRegistry).register({ + id: 'test.serial-state', + reconcileAfterUndo: async () => { + calls += 1; + active += 1; + maxActive = Math.max(maxActive, active); + if (calls === 1) { + markFirstStarted(); + await firstBlocked; + } + active -= 1; + }, + }); + + const first = ctx.get(IAgentConversationUndoService).undo(1); + await firstStarted; + const second = ctx.get(IAgentConversationUndoService).undo(1); + await Promise.resolve(); + + expect(calls).toBe(1); + expect(ctx.context.get().map((message) => message.role)).toEqual(['user', 'assistant']); + releaseFirst(); + await Promise.all([first, second]); + + expect(calls).toBe(2); + expect(maxActive).toBe(1); + expect(ctx.context.get()).toEqual([]); + }); + + it('publishes context.undone and tracks conversation_undo', async () => { + setup(); + ctx.get(IAgentConversationUndoService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + + await ctx.rpc.undoHistory({ count: 1 }); + + expect(records).toContainEqual({ + event: 'conversation_undo', + properties: { agent_id: 'main', count: 1 }, + }); + expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'assistant']); + }); + + it('clears lastPrompt when undo removes the only prompt', async () => { + setup(); + const metadata = ctx.get(ISessionMetadata); + await metadata.ready; + await metadata.update({ lastPrompt: 'u1' }); + ctx.appendTurnExchange('u1', 'a1'); + + await ctx.get(IAgentConversationUndoService).undo(1); + + await expect(metadata.read()).resolves.toMatchObject({ lastPrompt: undefined }); + }); + + it('uses the newest pending prompt as lastPrompt after undo', async () => { + setup(); + const metadata = ctx.get(ISessionMetadata); + await metadata.ready; + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + const list = vi.spyOn(ctx.get(IAgentPromptService), 'list').mockReturnValue({ + active: undefined, + pending: [ + { + id: 'queued', + userMessageId: 'queued', + createdAt: new Date(0).toISOString(), + state: 'pending', + message: { + role: 'user', + content: [{ type: 'text', text: 'queued prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + ], + }); + + try { + await ctx.get(IAgentConversationUndoService).undo(1); + await expect(metadata.read()).resolves.toMatchObject({ lastPrompt: 'queued prompt' }); + } finally { + list.mockRestore(); + } + }); + + it('treats metadata reconciliation failure as non-fatal after committing undo', async () => { + setup(); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + const update = vi.spyOn(ctx.get(ISessionMetadata), 'update').mockRejectedValueOnce( + new Error('metadata write failed'), + ); + const undone: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.undone', ({ turns }) => { + undone.push(turns); + }); + + try { + await expect(ctx.get(IAgentConversationUndoService).undo(1)).resolves.toBe(1); + + expect(ctx.context.get().map((message) => message.role)).toEqual(['user', 'assistant']); + expect(undone).toEqual([1]); + expect(records).toContainEqual({ + event: 'conversation_undo', + properties: { agent_id: 'main', count: 1 }, + }); + } finally { + subscription.dispose(); + update.mockRestore(); + } + }); + + it('persists context.undo without introducing a wire-level cut record', async () => { + setup(); + ctx.appendTurnExchange('u1', 'a1'); + + await ctx.get(IAgentConversationUndoService).undo(1); + await ctx.get(IWireService).flush(); + + const wireEvents = ctx.allEvents + .filter((event) => event.type === '[wire]') + .map((event) => event.event); + expect(wireEvents).toContain('context.undo'); + expect(wireEvents).not.toContain('log.cut'); + }); +}); diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 4f66c01450..f447eed466 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -107,7 +107,7 @@ function stubContextMemory(): IAgentContextMemoryService & { undo: (count) => { const cut = computeUndoCut(messages, count); if (cut.cutIndex >= 0 && cut.removedCount >= count) { - messages.splice(cut.cutIndex, messages.length - cut.cutIndex); + messages.splice(cut.cutIndex); } return cut; }, diff --git a/packages/agent-core-v2/test/app/gateway/gateway.test.ts b/packages/agent-core-v2/test/app/gateway/gateway.test.ts index d6d4b908eb..e2194cd5ed 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -56,7 +56,6 @@ describe('RestGateway', () => { abort: () => true, inject: () => Promise.resolve(undefined), retry: () => Promise.resolve(undefined), - undo: () => 0, clear: () => {}, hooks: createHooks(['onBeforeSubmitPrompt']) as IAgentPromptService['hooks'], }; diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 4e0b8a3f3a..7c9d868ca0 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -16,6 +16,7 @@ import { IAgentBlobService } from '#/agent/blob/agentBlobService'; import { AgentBlobServiceImpl } from '#/agent/blob/agentBlobServiceImpl'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { CHECKPOINTED_MODELS, type Checkpointed } from '#/agent/contextMemory/conversationTime'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { ISessionCronService } from '#/session/cron/sessionCronService'; import { SessionCronServiceImpl } from '#/session/cron/sessionCronServiceImpl'; @@ -144,6 +145,7 @@ import { import { IEventBus } from '#/app/event/eventBus'; import { IWireService } from '#/wire/wire'; import { WireService } from '#/wire/wireService'; +import { promptTurn } from '#/agent/loop/turnOps'; import { IModelService, type ModelsSection } from '#/kosong/model/model'; import { DEFAULT_MODEL_SECTION, @@ -358,6 +360,7 @@ interface ResumeStateSnapshot { readonly context: { readonly history: readonly ContextMessage[]; }; + readonly checkpointedModels: Readonly>; readonly permission: Omit, 'rules'>; readonly usage: Omit, 'currentTurn'>; } @@ -1460,6 +1463,18 @@ export class AgentTestContext { }); } + appendUserTurn(text: string): void { + this.get(IWireService).dispatch( + promptTurn({ input: [{ type: 'text', text }], origin: { kind: 'user' } }), + ); + this.appendMessage({ + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'user' }, + }); + } + appendSystemReminder( content: string, origin: ContextMessage['origin'] = { kind: 'injection', variant: 'system-reminder' }, @@ -1490,9 +1505,9 @@ export class AgentTestContext { this.get(IAgentPromptService).clear(); } - undoHistory(count: number): number { + async undoHistory(count: number): Promise { const rpcMethods = this.get(IAgentRPCService); - return rpcMethods.undoHistory({ count }) as unknown as number; + return rpcMethods.undoHistory({ count }); } newEvents(): EventSnapshot { @@ -1579,6 +1594,16 @@ export class AgentTestContext { this.coverUsage(tokenTotal); } + appendTurnExchange(userText: string, assistantText: string, tokenTotal?: number): void { + this.appendUserTurn(userText); + this.appendAssistantMessage({ + role: 'assistant', + content: [{ type: 'text', text: assistantText }], + toolCalls: [], + }); + this.coverUsage(tokenTotal); + } + appendAssistantText(step: number, text: string): void { this.appendAssistantTextWithUsage(step, text); } @@ -2182,6 +2207,12 @@ function resumeStateSnapshot(ctx: AgentTestContext): ResumeStateSnapshot { return { config: configStateSnapshot(ctx), context: resumeContextSnapshot(ctx), + checkpointedModels: Object.fromEntries( + CHECKPOINTED_MODELS.map((model) => [ + model.name, + (ctx.get(IWireService).getModel(model) as Checkpointed).current, + ]), + ), permission: permissionData, usage: usageStatus, }; diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index a8131c9f25..db7cd48bad 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { WIRE_PROTOCOL_VERSION, + CHECKPOINTED_MODELS, IAgentContextMemoryService, IAgentContextSizeService, IAgentGoalService, @@ -23,6 +24,7 @@ import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; import { todoSet, TodoModel } from '#/session/todo/todoOps'; import { OP_REGISTRY } from '#/wire/op'; +import { MODEL_CROSS_REDUCERS } from '#/wire/model'; import { IWireService } from '#/wire/wire'; import { AGENT_WIRE_RECORD_KEY } from '#/wire/record'; import { registerTestAgentWire, restoreTestAgentWire } from './wire/stubs'; @@ -162,7 +164,42 @@ describe('v1 wire vocabulary', () => { await restoreTestAgentWire(fresh, log2, SCOPE, records); - expect(fresh.getModel(TodoModel)).toEqual([{ title: 'restore me', status: 'in_progress' }]); + expect(fresh.getModel(TodoModel).current).toEqual([ + { title: 'restore me', status: 'in_progress' }, + ]); + }); +}); + +describe('conversation-time checkpoint registration', () => { + // Models that react to context.* records but deliberately stay on world time + // (ephemeral notice state that must not travel through undo) are exempt. + // Registering a new context-reacting model without `defineCheckpointedModel` + // fails this test — add the name here only with a justification. + const CHECKPOINT_EXEMPT_MODELS: ReadonlySet = new Set([ + // goalForkNotice is one-shot reminder bookkeeping, not conversation state. + 'goalForkNotice', + ]); + const CONTEXT_OPS = [ + 'context.append_message', + 'context.apply_compaction', + 'context.clear', + 'context.undo', + ]; + + it('registers every context-reacting model as checkpointed or explicitly exempt', () => { + const violations: string[] = []; + let entries = 0; + for (const opType of CONTEXT_OPS) { + for (const entry of MODEL_CROSS_REDUCERS.get(opType) ?? []) { + entries += 1; + if (CHECKPOINTED_MODELS.includes(entry.model)) continue; + if (CHECKPOINT_EXEMPT_MODELS.has(entry.model.name)) continue; + violations.push(`${entry.model.name} (on ${opType})`); + } + } + // Guard against a vacuous pass when module loading changes. + expect(entries).toBeGreaterThan(0); + expect(violations).toEqual([]); }); }); diff --git a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts index c970b352ee..47c7158550 100644 --- a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: session-shared Todo state, including undo restoration. + * Responsibility: SessionTodoService exposes the main wire state and emits observable changes. + * Wiring: lightweight lifecycle/agent fakes with real event-bus behavior. + * Run: pnpm --filter @moonshot-ai/agent-core-v2 test -- test/session/todo/sessionTodo.test.ts + */ + import { describe, expect, it } from 'vitest'; import type { ServiceIdentifier, ServicesAccessor } from '#/_base/di/instantiation'; @@ -8,7 +15,10 @@ import { Emitter } from '#/_base/event'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionTodoService } from '#/session/todo/sessionTodo'; @@ -27,6 +37,7 @@ interface FakeAgent { readonly registeredTools: string[]; readonly registeredVariants: string[]; readonly appended: RecordedTodoSet[]; + readonly eventBus: EventBusService; readonly restore: (records: readonly WireRecord[]) => Promise; } @@ -34,6 +45,7 @@ function makeFakeAgent(agentId: string): FakeAgent { const registeredTools: string[] = []; const registeredVariants: string[] = []; const appended: RecordedTodoSet[] = []; + const eventBus = new EventBusService(); let todoState: readonly TodoItem[] = []; @@ -97,7 +109,7 @@ function makeFakeAgent(agentId: string): FakeAgent { }, restore: async () => {}, flush: async () => {}, - getModel: () => todoState, + getModel: () => ({ current: todoState, checkpoints: [] }), subscribe: () => toDisposable(() => {}), } as unknown as IWireService; @@ -108,6 +120,8 @@ function makeFakeAgent(agentId: string): FakeAgent { if (id === IInstantiationService) return instantiationStub as unknown as T; if (id === IAgentContextMemoryService) return memoryStub as unknown as T; if (id === IAgentProfileService) return profileStub as unknown as T; + if (id === IAgentToolPolicyService) return profileStub as unknown as T; + if (id === IEventBus) return eventBus as unknown as T; if (id === IWireService) return wireStub as unknown as T; throw new Error(`unexpected service request in fake agent: ${String(id)}`); }, @@ -125,6 +139,7 @@ function makeFakeAgent(agentId: string): FakeAgent { registeredTools, registeredVariants, appended, + eventBus, restore, }; } @@ -205,6 +220,24 @@ describe('SessionTodoService', () => { ]); }); + it('fires the restored list once when undo changes the main wire state', async () => { + const main = makeFakeAgent('main'); + const lifecycle = makeLifecycleStub([main.handle]); + const service = new SessionTodoService(lifecycle.service); + service.setTodos([{ title: 'doomed', status: 'in_progress' }]); + + const seen: Array = []; + const subscription = service.onDidChange((todos) => seen.push(todos)); + await main.restore([ + { type: 'tools.update_store', key: 'todo', value: [{ title: 'kept', status: 'pending' }] }, + ]); + main.eventBus.publish({ type: 'context.undone', turns: 1 }); + main.eventBus.publish({ type: 'context.undone', turns: 1 }); + subscription.dispose(); + + expect(seen).toEqual([[{ title: 'kept', status: 'pending' }]]); + }); + it('appends a tools.update_store record to the main agent wire on setTodos', () => { const main = makeFakeAgent('main'); const lifecycle = makeLifecycleStub([main.handle]); diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index f0e3adbfb7..fa98629ac1 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -294,6 +294,13 @@ function createAgentLifecycleStub(options: AgentLifecycleStubOptions = {}): Agen unregister: () => {}, } as never; } + if (serviceId === IEventBus) { + return { + _serviceBrand: undefined, + publish: () => {}, + subscribe: () => noopDisposable(), + } as never; + } if (serviceId === IWireService) { return { _serviceBrand: undefined, diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index 92afce4d32..337a69c17c 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -4,6 +4,10 @@ import { join } from 'pathe'; import { describe, expect, it, vi } from 'vitest'; +import { + resetUnexpectedErrorHandler, + setUnexpectedErrorHandler, +} from '#/_base/errors/unexpectedError'; import { WIRE_PROTOCOL_VERSION, IAgentGoalService, @@ -170,6 +174,43 @@ describe('Agent resume', () => { }); }); + it('restores a cancelled queued-turn gap before allocating the next turn', async () => { + const persistence = new RecordingAgentPersistence([ + resumeConfigRecord(), + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'Historical prompt' }], + origin: { kind: 'user' }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Historical prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.begin', uuid: 'historical-step', turnId: '0' }, + }, + { + type: 'context.append_loop_event', + event: { type: 'step.end', uuid: 'historical-step', turnId: '0' }, + }, + { type: 'turn.cancel', turnId: 1, target: 'queued' }, + ] as WireRecord[]); + const ctx = testAgent({ persistence, autoConfigure: false }); + + await ctx.restorePersisted(); + ctx.mockNextResponse({ type: 'text', text: 'Fresh response.' }); + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'Fresh prompt' }] }); + await ctx.untilTurnEnd(); + + expect(findRpcEvent(ctx.allEvents, 'turn.started')?.args).toMatchObject({ turnId: 2 }); + }); + it('projects restored pending tool results before later user messages', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), @@ -772,6 +813,47 @@ describe('Agent resume', () => { expect(ctx.context.get()[0]?.role).toBe('user'); expect(ctx.context.get()[1]?.role).toBe('assistant'); }); + + it('skips a fractional undo record on resume without corrupting checkpointed state', async () => { + const unexpected: unknown[] = []; + setUnexpectedErrorHandler((error) => unexpected.push(error)); + const persistence = new RecordingAgentPersistence([ + { + type: 'metadata', + protocol_version: '1.4', + created_at: 1, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'keep me' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { type: 'context.undo', count: 0.5 }, + ] as unknown as WireRecord[]); + const ctx = testAgent({ persistence, autoConfigure: false }); + + try { + await ctx.restorePersisted(); + + expect(ctx.context.get()).toHaveLength(1); + await expect(ctx.get(IAgentPlanService).status()).resolves.toBeNull(); + expect(unexpected).toHaveLength(1); + expect(unexpected[0]).toMatchObject({ + code: 'wire.unknown_record', + details: { type: 'context.undo', index: 1 }, + }); + } finally { + try { + await ctx.dispose(); + } finally { + resetUnexpectedErrorHandler(); + } + } + }); }); class RecordingAgentPersistence extends InMemoryWireRecordPersistence { diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index 2886902f94..1623004e09 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -21,7 +21,7 @@ * the native v2 services directly (`ISessionLifecycleService.fork` / `archive` / `restore`, * `IAgentFullCompactionService.begin`, `IAgentRPCService.cancel`); there is no * v1-only projection to centralize, so no adapter is involved. `undo` likewise - * calls `IAgentPromptService.undo` directly (it now throws + * calls `IAgentConversationUndoService.undo` directly (it throws * `session.undo_unavailable` with a structured reason) and only borrows * `ISessionLegacyService.status` for the cross-domain status rollup. The * `/sessions/{id}/children` endpoints call `ISessionLifecycleService.createChild` @@ -77,7 +77,7 @@ import { ErrorCodes, IAgentContextMemoryService, IAgentProfileService, - IAgentPromptService, + IAgentConversationUndoService, IAgentFullCompactionService, IAgentRPCService, IAuthSummaryService, @@ -691,12 +691,11 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void if (parsed.action === 'undo') { const body = undoSessionRequestSchema.parse(req.body); const agent = await resolveMainAgent(core, parsed.id); - // `prompt.undo` throws `session.undo_unavailable` (with a structured - // `reason`) when the history cannot satisfy `count`; it is a no-op - // until the precheck passes, so the post-undo read below always sees - // the cut applied. The status rollup stays in the legacy adapter - // (cross-domain) and is reused here verbatim. - agent.accessor.get(IAgentPromptService).undo(body.count); + // The conversation undo service throws `session.undo_unavailable` (with a + // structured `reason`) when fewer than `count` turns may be cut; + // it quiesces the loop/compaction first, so the post-undo read + // below always sees the cut applied. + await agent.accessor.get(IAgentConversationUndoService).undo(body.count); const history = agent.accessor.get(IAgentContextMemoryService).get(); requestLog(req)?.info({ session_id: parsed.id, action: 'undo' }, 'session action completed'); const [summary, status] = await Promise.all([ @@ -1219,6 +1218,7 @@ function sendMappedError( reply.send(errEnvelope(ErrorCode.SESSION_NOT_FOUND, err.message, requestId, err.stack)); return; case 'session.fork_active_turn': + case ErrorCodes.SESSION_BUSY: reply.send(errEnvelope(ErrorCode.SESSION_BUSY, err.message, requestId, err.stack)); return; case 'compaction.unable': diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 69cda99b17..1e50124d03 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -1,7 +1,7 @@ /** * Scenario: v1-compatible session routes, including blocked-goal Web resume. * Responsibilities: verify HTTP envelopes, persisted reads, and session actions. - * Wiring: real kap-server; goal-resume cases observe the agent event stream. + * Wiring: real kap-server; route errors stub the agent service contract. * Run: `pnpm --filter @moonshot-ai/kap-server exec vitest run test/sessions.test.ts`. */ import { randomBytes } from 'node:crypto'; @@ -10,11 +10,14 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { inflateRawSync } from 'node:zlib'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + Error2, + ErrorCodes, IBootstrapService, type DomainEvent, + IAgentConversationUndoService, IAgentGoalService, IAgentLifecycleService, IEventBus, @@ -614,8 +617,35 @@ describe('server-v2 /api/v1/sessions', () => { expect(res.body.code).toBe(40911); expect(res.body.msg).toMatch(/nothing to undo/i); // The thrown Error2's stack is surfaced so operators can locate the - // source — the precheck/throw now lives in the native prompt service. - expect(res.body.stack).toEqual(expect.stringContaining('promptService')); + // source — the precheck/throw now lives in the undo service. + expect(res.body.stack).toEqual(expect.stringContaining('undoService')); + }); + + it('returns 40901 when :undo reports a busy session', async () => { + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const session = (server as RunningServer).core.accessor + .get(ISessionLifecycleService) + .get(created.body.data.id); + if (session === undefined) throw new Error('expected live session'); + const agent = await session.accessor + .get(IAgentLifecycleService) + .create({ agentId: MAIN_AGENT_ID }); + const undo = vi + .spyOn(agent.accessor.get(IAgentConversationUndoService), 'undo') + .mockRejectedValue(new Error2(ErrorCodes.SESSION_BUSY, 'session is busy')); + + try { + const response = await postJson( + `/api/v1/sessions/${created.body.data.id}:undo`, + { count: 1 }, + ); + + expect(response.body.code).toBe(40901); + } finally { + undo.mockRestore(); + } }); it('rejects an unsupported action suffix (40001)', async () => { From 48bf3d4c28d5d595dee66b175fbc3f6a9f02b338 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 27 Jul 2026 12:07:58 +0800 Subject: [PATCH 04/11] feat(kap-server): bundle the desktop app log into session exports on request (#2223) * feat(kap-server): bundle the desktop app log into session exports on request * refactor(agent-core-v2): align the desktop log export with repo conventions --- .../src/app/sessionExport/sessionExport.ts | 2 + .../app/sessionExport/sessionExportService.ts | 27 ++++++++-- .../app/sessionExport/sessionExport.test.ts | 54 +++++++++++++++++++ .../kap-server/src/protocol/rest-session.ts | 4 ++ .../kap-server/src/routes/sessionExport.ts | 3 ++ packages/kap-server/test/sessions.test.ts | 30 +++++++++++ .../src/__tests__/rest-session.test.ts | 4 ++ packages/protocol/src/rest/session.ts | 4 ++ 8 files changed, 124 insertions(+), 4 deletions(-) diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts index 108c5c44a9..a634941e3d 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExport.ts @@ -21,6 +21,7 @@ export interface ExportSessionPayload { readonly sessionId: string; readonly outputPath?: string | undefined; readonly includeGlobalLog?: boolean | undefined; + readonly includeDesktopLog?: boolean; readonly version: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; @@ -39,6 +40,7 @@ export interface ExportSessionManifest { readonly workspaceDir?: string | undefined; readonly sessionLogPath?: string | undefined; readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string; readonly webLogPath?: string; readonly installSource?: string | undefined; readonly shellEnv?: ShellEnvironment | undefined; diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index a2fd536d47..17e59b7976 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -39,6 +39,7 @@ import { openZipSource, type ZipSource } from './file-source'; const SESSION_LOG_REL = 'logs/kimi-code.log'; const GLOBAL_LOG_REL = 'logs/global/kimi-code.log'; const WEB_LOG_REL = 'logs/kimi-web.jsonl'; +const DESKTOP_LOG_REL = 'logs/kimi-desktop.log'; export class SessionExportService implements ISessionExportService { declare readonly _serviceBrand: undefined; @@ -85,6 +86,10 @@ export class SessionExportService implements ISessionExportService { request: input, summary: liveSummary, globalLogPath: resolveGlobalLogPath(this.bootstrap.homeDir), + desktopLogPath: + input.includeDesktopLog === true + ? join(this.bootstrap.homeDir, 'logs', 'kimi-code-desktop.log') + : undefined, webLog: options.webLog, signal: options.signal, maxArchiveBytes: options.maxArchiveBytes, @@ -158,6 +163,7 @@ export async function exportSessionDirectory(input: { readonly request: ExportSessionPayload; readonly summary: ExportSessionDirectorySummary; readonly globalLogPath?: string | undefined; + readonly desktopLogPath?: string | undefined; readonly webLog?: string; readonly signal?: AbortSignal; readonly maxArchiveBytes?: number; @@ -169,12 +175,17 @@ export async function exportSessionDirectory(input: { let sessionLogSourceTransferred = false; let globalSource: ZipSource | undefined; let globalSourceTransferred = false; + let desktopSource: ZipSource | undefined; + let desktopSourceTransferred = false; try { sessionLogSource = await openOptionalZipSource(sessionLogPath, input.signal); if (input.request.includeGlobalLog === true && input.globalLogPath !== undefined) { globalSource = await openOptionalZipSource(input.globalLogPath, input.signal); } + if (input.desktopLogPath !== undefined) { + desktopSource = await openOptionalZipSource(input.desktopLogPath, input.signal); + } const sessionFiles = await collectFilesRecursive(sessionDir); if (sessionFiles.length === 0 && sessionLogSource === undefined) { throw new Error2( @@ -218,10 +229,14 @@ export async function exportSessionDirectory(input: { if (globalSource !== undefined) { extras.push({ source: globalSource, target: GLOBAL_LOG_REL }); } - const manifest = - globalSource === undefined - ? baseManifest - : { ...baseManifest, globalLogPath: GLOBAL_LOG_REL }; + if (desktopSource !== undefined) { + extras.push({ source: desktopSource, target: DESKTOP_LOG_REL }); + } + const manifest = { + ...baseManifest, + globalLogPath: globalSource === undefined ? undefined : GLOBAL_LOG_REL, + desktopLogPath: desktopSource === undefined ? undefined : DESKTOP_LOG_REL, + }; const writing = writeExportZip({ outputPath, @@ -234,6 +249,7 @@ export async function exportSessionDirectory(input: { }); sessionLogSourceTransferred = sessionLogSource !== undefined; globalSourceTransferred = globalSource !== undefined; + desktopSourceTransferred = desktopSource !== undefined; const entries = await writing; return { @@ -249,6 +265,9 @@ export async function exportSessionDirectory(input: { if (globalSource !== undefined && !globalSourceTransferred) { await globalSource.close().catch(() => {}); } + if (desktopSource !== undefined && !desktopSourceTransferred) { + await desktopSource.close().catch(() => {}); + } } } diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 9c1230c6b8..7cfdab0be9 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -517,6 +517,60 @@ describe('sessionExport', () => { ); }); + it('includes the desktop app log when given', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_desktop_log'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + const desktopLogPath = join(tmp, 'logs', 'kimi-code-desktop.log'); + await mkdir(join(tmp, 'logs'), { recursive: true }); + const desktopLog = '2026-07-27T00:00:00.000Z INFO [renderer] hello\n'; + await writeFile(desktopLogPath, desktopLog, 'utf-8'); + const outputPath = join(tmp, 'desktop-log.zip'); + + const result = await exportSessionDirectory({ + request: { + sessionId: 'ses_desktop_log', + outputPath, + version: '1.0.0-test', + }, + summary: { + id: 'ses_desktop_log', + sessionDir, + }, + desktopLogPath, + }); + + expect(result.entries).toContain('logs/kimi-desktop.log'); + expect(result.manifest.desktopLogPath).toBe('logs/kimi-desktop.log'); + await expect(readZipEntry(outputPath, 'logs/kimi-desktop.log')).resolves.toEqual( + Buffer.from(desktopLog, 'utf8'), + ); + }); + + it('skips a missing desktop app log silently', async () => { + const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); + const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_desktop_log_missing'); + await mkdir(sessionDir, { recursive: true }); + await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8'); + + const result = await exportSessionDirectory({ + request: { + sessionId: 'ses_desktop_log_missing', + outputPath: join(tmp, 'desktop-log-missing.zip'), + version: '1.0.0-test', + }, + summary: { + id: 'ses_desktop_log_missing', + sessionDir, + }, + desktopLogPath: join(tmp, 'logs', 'does-not-exist.log'), + }); + + expect(result.entries).not.toContain('logs/kimi-desktop.log'); + expect(result.manifest.desktopLogPath).toBeUndefined(); + }); + it('rejects when a collected file disappears before it can be archived', async () => { const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-')); const removedPath = join(tmp, 'removed-state.json'); diff --git a/packages/kap-server/src/protocol/rest-session.ts b/packages/kap-server/src/protocol/rest-session.ts index 57528dfe1b..4cf33af6a7 100644 --- a/packages/kap-server/src/protocol/rest-session.ts +++ b/packages/kap-server/src/protocol/rest-session.ts @@ -94,6 +94,10 @@ export const exportSessionRequestSchema = z message: `web_log must not exceed ${MAX_SESSION_EXPORT_WEB_LOG_BYTES} UTF-8 bytes`, }) .optional(), + // Desktop hosts set this to bundle the on-disk desktop app log + // (`/logs/kimi-code-desktop.log`) into the archive; the server reads + // the file itself, so no log content crosses the request. + desktop: z.boolean().optional(), }) .strict(); export type ExportSessionRequest = z.infer; diff --git a/packages/kap-server/src/routes/sessionExport.ts b/packages/kap-server/src/routes/sessionExport.ts index 800766ffa5..2a13dec730 100644 --- a/packages/kap-server/src/routes/sessionExport.ts +++ b/packages/kap-server/src/routes/sessionExport.ts @@ -119,6 +119,9 @@ export function registerSessionExportRoute( sessionId: req.params.session_id, outputPath, includeGlobalLog: true, + // Desktop hosts ask for their own app log via `desktop: true`; + // the file is read server-side (missing files are skipped). + includeDesktopLog: req.body.desktop === true, version: options.serverVersion, }, { diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 1e50124d03..0073418f39 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -227,6 +227,36 @@ describe('server-v2 /api/v1/sessions', () => { expect(body.details?.[0]?.path).toBe('web_log'); }); + it('bundles the on-disk desktop app log when the desktop flag is set', async () => { + const created = await postJson('/api/v1/sessions', { + metadata: { cwd: home as string }, + }); + const id = created.body.data.id; + await mkdir(join(home as string, 'logs'), { recursive: true }); + await writeFile( + join(home as string, 'logs', 'kimi-code-desktop.log'), + '2026-07-27T00:00:00.000Z INFO [renderer] hello\n', + 'utf-8', + ); + + const res = await fetch(`${base}/api/v1/sessions/${id}/export`, { + method: 'POST', + headers: authHeaders(server as RunningServer, { 'content-type': 'application/json' }), + body: JSON.stringify({ desktop: true }), + } as never); + const archive = Buffer.from(await res.arrayBuffer()); + + expect(res.status).toBe(200); + const entries = readZipEntries(archive); + const manifest = JSON.parse(entries.get('manifest.json')?.toString('utf8') ?? 'null') as { + desktopLogPath?: string; + }; + expect(entries.get('logs/kimi-desktop.log')?.toString('utf8')).toBe( + '2026-07-27T00:00:00.000Z INFO [renderer] hello\n', + ); + expect(manifest.desktopLogPath).toBe('logs/kimi-desktop.log'); + }); + async function createStoppedGoalRig(status: 'paused' | 'blocked') { const cwd = home as string; const created = await postJson('/api/v1/sessions', { metadata: { cwd } }); diff --git a/packages/protocol/src/__tests__/rest-session.test.ts b/packages/protocol/src/__tests__/rest-session.test.ts index 721b59650e..849aa59d3b 100644 --- a/packages/protocol/src/__tests__/rest-session.test.ts +++ b/packages/protocol/src/__tests__/rest-session.test.ts @@ -32,6 +32,10 @@ describe('exportSessionRequestSchema', () => { }); }); + it('accepts the desktop log flag', () => { + expect(exportSessionRequestSchema.parse({ desktop: true })).toEqual({ desktop: true }); + }); + it('accepts a Web log at the 256 KiB UTF-8 boundary', () => { expect(exportSessionRequestSchema.safeParse({ web_log: 'a'.repeat(256 * 1024) }).success).toBe( true, diff --git a/packages/protocol/src/rest/session.ts b/packages/protocol/src/rest/session.ts index bf1d5aec49..c3e0c3b8a6 100644 --- a/packages/protocol/src/rest/session.ts +++ b/packages/protocol/src/rest/session.ts @@ -74,6 +74,10 @@ export const exportSessionRequestSchema = z message: `web_log must not exceed ${MAX_SESSION_EXPORT_WEB_LOG_BYTES} UTF-8 bytes`, }) .optional(), + // Desktop hosts set this to bundle the on-disk desktop app log + // (`/logs/kimi-code-desktop.log`) into the archive; the server reads + // the file itself, so no log content crosses the request. + desktop: z.boolean().optional(), }) .strict(); export type ExportSessionRequest = z.infer; From 8a45f10eddbb35c317047e82e567cdb59a220b4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 14:28:12 +0800 Subject: [PATCH 05/11] ci: release packages (#2145) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .changeset/agent-scope-state-container.md | 5 ----- .changeset/defer-user-tools.md | 5 ----- .changeset/eager-scope-instantiation.md | 5 ----- .changeset/fix-web-clipboard-paste.md | 5 ----- .changeset/goal-max-steps-continue.md | 5 ----- .changeset/goal-steer-queued-messages.md | 5 ----- .changeset/session-scope-state-container.md | 5 ----- .changeset/undo-rewind-consistency.md | 5 ----- apps/kimi-code/CHANGELOG.md | 20 ++++++++++++++++++++ apps/kimi-code/package.json | 2 +- 10 files changed, 21 insertions(+), 41 deletions(-) delete mode 100644 .changeset/agent-scope-state-container.md delete mode 100644 .changeset/defer-user-tools.md delete mode 100644 .changeset/eager-scope-instantiation.md delete mode 100644 .changeset/fix-web-clipboard-paste.md delete mode 100644 .changeset/goal-max-steps-continue.md delete mode 100644 .changeset/goal-steer-queued-messages.md delete mode 100644 .changeset/session-scope-state-container.md delete mode 100644 .changeset/undo-rewind-consistency.md diff --git a/.changeset/agent-scope-state-container.md b/.changeset/agent-scope-state-container.md deleted file mode 100644 index dff36e468d..0000000000 --- a/.changeset/agent-scope-state-container.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Hold per-agent runtime state of the experimental engine in the agent-scope state container, so it is observable in one place and disposed with the agent; state snapshots collapse class instances to name markers so resource graphs cannot exhaust memory during export. diff --git a/.changeset/defer-user-tools.md b/.changeset/defer-user-tools.md deleted file mode 100644 index 5396a725ee..0000000000 --- a/.changeset/defer-user-tools.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Allow hosts to defer registered user-tool schemas until needed. Set `disclosure: "deferred"` when registering a tool. diff --git a/.changeset/eager-scope-instantiation.md b/.changeset/eager-scope-instantiation.md deleted file mode 100644 index bef351871d..0000000000 --- a/.changeset/eager-scope-instantiation.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Instantiate every registered service eagerly at scope creation on the experimental engine, following the dependency graph automatically, and drop the hand-maintained lists that resolved side-effect services one by one at startup. diff --git a/.changeset/fix-web-clipboard-paste.md b/.changeset/fix-web-clipboard-paste.md deleted file mode 100644 index c13dac077d..0000000000 --- a/.changeset/fix-web-clipboard-paste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -web: Fix copying selected chat text over plain HTTP from replacing the clipboard with an event placeholder. diff --git a/.changeset/goal-max-steps-continue.md b/.changeset/goal-max-steps-continue.md deleted file mode 100644 index ff33430f99..0000000000 --- a/.changeset/goal-max-steps-continue.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix goal pursuit being interrupted when a goal turn reaches the per-turn step limit (`loop_control.max_steps_per_turn`); the limit now splits goal work into more continuation turns instead of pausing the goal. diff --git a/.changeset/goal-steer-queued-messages.md b/.changeset/goal-steer-queued-messages.md deleted file mode 100644 index ddb5b5d849..0000000000 --- a/.changeset/goal-steer-queued-messages.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix messages sent while a goal is running being rejected with a "Cannot launch a new turn while another turn is active" error; they are now steered into the active goal turn instead of being dropped. diff --git a/.changeset/session-scope-state-container.md b/.changeset/session-scope-state-container.md deleted file mode 100644 index de22c0e6de..0000000000 --- a/.changeset/session-scope-state-container.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Hold per-session runtime state of the experimental engine in the session-scope state container, so it is observable in one place and disposed with the session. diff --git a/.changeset/undo-rewind-consistency.md b/.changeset/undo-rewind-consistency.md deleted file mode 100644 index 0a751fb58d..0000000000 --- a/.changeset/undo-rewind-consistency.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. diff --git a/apps/kimi-code/CHANGELOG.md b/apps/kimi-code/CHANGELOG.md index 33cfee6e21..5cee1e0955 100644 --- a/apps/kimi-code/CHANGELOG.md +++ b/apps/kimi-code/CHANGELOG.md @@ -1,5 +1,25 @@ # @moonshot-ai/kimi-code +## 0.29.2 + +### Patch Changes + +- [#2192](https://github.com/MoonshotAI/kimi-code/pull/2192) [`7799bd7`](https://github.com/MoonshotAI/kimi-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Hold per-agent runtime state of the experimental engine in the agent-scope state container, so it is observable in one place and disposed with the agent; state snapshots collapse class instances to name markers so resource graphs cannot exhaust memory during export. + +- [#2119](https://github.com/MoonshotAI/kimi-code/pull/2119) [`f06eb5c`](https://github.com/MoonshotAI/kimi-code/commit/f06eb5c60e0a4e51162d1854dda1db41892b457c) Thanks [@pvzheroes125](https://github.com/pvzheroes125)! - Allow hosts to defer registered user-tool schemas until needed. Set `disclosure: "deferred"` when registering a tool. + +- [#2192](https://github.com/MoonshotAI/kimi-code/pull/2192) [`7799bd7`](https://github.com/MoonshotAI/kimi-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Instantiate every registered service eagerly at scope creation on the experimental engine, following the dependency graph automatically, and drop the hand-maintained lists that resolved side-effect services one by one at startup. + +- [#2120](https://github.com/MoonshotAI/kimi-code/pull/2120) [`0d00a07`](https://github.com/MoonshotAI/kimi-code/commit/0d00a07c02e334ca904077b2ea8c56cf58b44586) Thanks [@yicun](https://github.com/yicun)! - web: Fix copying selected chat text over plain HTTP from replacing the clipboard with an event placeholder. + +- [#2210](https://github.com/MoonshotAI/kimi-code/pull/2210) [`0cef160`](https://github.com/MoonshotAI/kimi-code/commit/0cef160c4b900a3d78212cd5da4b80d335ea0b6f) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix goal pursuit being interrupted when a goal turn reaches the per-turn step limit (`loop_control.max_steps_per_turn`); the limit now splits goal work into more continuation turns instead of pausing the goal. + +- [#2153](https://github.com/MoonshotAI/kimi-code/pull/2153) [`c497af6`](https://github.com/MoonshotAI/kimi-code/commit/c497af60e6cd20aab05e590f98a28fb15dd3491d) Thanks [@chengluyu](https://github.com/chengluyu)! - Fix messages sent while a goal is running being rejected with a "Cannot launch a new turn while another turn is active" error; they are now steered into the active goal turn instead of being dropped. + +- [#2192](https://github.com/MoonshotAI/kimi-code/pull/2192) [`7799bd7`](https://github.com/MoonshotAI/kimi-code/commit/7799bd7346aaee11ec2b6d6883e1e4fe5ab10717) Thanks [@sailist](https://github.com/sailist)! - Hold per-session runtime state of the experimental engine in the session-scope state container, so it is observable in one place and disposed with the session. + +- [#2055](https://github.com/MoonshotAI/kimi-code/pull/2055) [`d40d0d3`](https://github.com/MoonshotAI/kimi-code/commit/d40d0d305d2866cb5ab8696e559e0813b5f92201) Thanks [@7Sageer](https://github.com/7Sageer)! - Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. + ## 0.29.1 ### Patch Changes diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 7f15f7ff8d..ec17e64721 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/kimi-code", - "version": "0.29.1", + "version": "0.29.2", "description": "The Starting Point for Next-Gen Agents", "license": "MIT", "author": "Moonshot AI", From a9af42e6987044e0ec8d21cfe693dfe96e21f1a4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Mon, 27 Jul 2026 15:04:24 +0800 Subject: [PATCH 06/11] docs(changelog): sync 0.29.2 from apps/kimi-code/CHANGELOG.md (#2236) --- docs/en/release-notes/changelog.md | 9 +++++++++ docs/zh/release-notes/changelog.md | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/docs/en/release-notes/changelog.md b/docs/en/release-notes/changelog.md index 5a0c777505..538294a180 100644 --- a/docs/en/release-notes/changelog.md +++ b/docs/en/release-notes/changelog.md @@ -6,6 +6,15 @@ outline: 2 This page documents the changes in each Kimi Code CLI release. +## 0.29.2 (2026-07-27) + +### Bug Fixes + +- Fix goal pursuit pausing when a goal turn hits the per-turn step limit (`loop_control.max_steps_per_turn`). +- Fix messages sent during goal pursuit being rejected. +- Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. +- web: Fix copying selected chat text over plain HTTP overwriting the clipboard with an event placeholder. + ## 0.29.1 (2026-07-24) ### Features diff --git a/docs/zh/release-notes/changelog.md b/docs/zh/release-notes/changelog.md index 4f8815cad1..5b65a6fa2c 100644 --- a/docs/zh/release-notes/changelog.md +++ b/docs/zh/release-notes/changelog.md @@ -6,6 +6,15 @@ outline: 2 本页记录 Kimi Code CLI 每个版本的变更内容。 +## 0.29.2(2026-07-27) + +### 修复 + +- 修复目标执行在单轮达到步数上限(`loop_control.max_steps_per_turn`)后暂停的问题。 +- 修复目标运行期间发送的消息被拒绝的问题。 +- 修复 /undo 无法一致恢复对话历史、待办列表、计划模式和任务通知的问题。 +- web: 修复纯 HTTP 环境下复制选中聊天文本时,剪贴板被事件占位符覆盖的问题。 + ## 0.29.1(2026-07-24) ### 新功能 From 3b017821cf7bbc35a8abf2526593577de15ed93b Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 16:45:18 +0800 Subject: [PATCH 07/11] feat(kap-server): accept secondary_model in the config API (#2228) * feat(kap-server): accept secondary_model in the config API POST /api/v1/config now accepts secondary_model, persisted to the [secondary_model] config section via the generic per-domain dispatch. GET /config also hides the synthesized __secondary__ derived entry from the models view, matching the GET /models listing. * Update config-api-secondary-model.md Signed-off-by: 7Sageer --------- Signed-off-by: 7Sageer --- .changeset/config-api-secondary-model.md | 5 +++ .../kap-server/src/protocol/rest-config.ts | 2 + packages/kap-server/src/routes/config.ts | 36 +++++++++++++++--- packages/kap-server/test/config.test.ts | 37 ++++++++++++++++++- 4 files changed, 73 insertions(+), 7 deletions(-) create mode 100644 .changeset/config-api-secondary-model.md diff --git a/.changeset/config-api-secondary-model.md b/.changeset/config-api-secondary-model.md new file mode 100644 index 0000000000..083a0a7cd2 --- /dev/null +++ b/.changeset/config-api-secondary-model.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Allow updating the subagent secondary model through the configuration API. diff --git a/packages/kap-server/src/protocol/rest-config.ts b/packages/kap-server/src/protocol/rest-config.ts index 5dcb7d45af..ef2b478c43 100644 --- a/packages/kap-server/src/protocol/rest-config.ts +++ b/packages/kap-server/src/protocol/rest-config.ts @@ -13,6 +13,7 @@ export const configResponseSchema = z.object({ default_provider: z.string().optional(), default_model: z.string().optional(), models: z.record(z.string(), z.unknown()).optional(), + secondary_model: z.unknown().optional(), thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), @@ -36,6 +37,7 @@ export const patchConfigRequestSchema = z.object({ default_provider: z.string().optional(), default_model: z.string().optional(), models: z.record(z.string(), z.unknown()).optional(), + secondary_model: z.unknown().optional(), thinking: z.unknown().optional(), plan_mode: z.boolean().optional(), yolo: z.boolean().optional(), diff --git a/packages/kap-server/src/routes/config.ts b/packages/kap-server/src/routes/config.ts index c39347f076..caa94c6cde 100644 --- a/packages/kap-server/src/routes/config.ts +++ b/packages/kap-server/src/routes/config.ts @@ -14,7 +14,8 @@ * that: * - projects `getAll()` (camelCase resolved config) into the snake_case * `ConfigResponse`, redacting provider credentials to `has_api_key` - * (mirrors v1 `toConfigResponse`); + * (mirrors v1 `toConfigResponse`) and hiding the synthesized + * `__secondary__` derived entry from `models` (mirrors `GET /models`); * - splits v1's flat multi-domain `POST /config` patch into per-domain * `IConfigService.set(domain, value)` calls (snake_case → camelCase); * - republishes the change as a v2 `DomainEvent` on `IEventService`. @@ -26,7 +27,12 @@ * response (the schema contract) is unaffected. */ -import { IConfigService, IEventService, type Scope } from '@moonshot-ai/agent-core-v2'; +import { + IConfigService, + IEventService, + SECONDARY_DERIVED_MODEL_ID, + type Scope, +} from '@moonshot-ai/agent-core-v2'; import { errEnvelope, okEnvelope } from '../envelope'; import { requestLog } from '../lib/requestLog'; @@ -126,14 +132,20 @@ export function registerConfigRoutes(app: ConfigRouteHost, core: Scope): void { // Edge facade — project the v2 resolved config into the v1 `ConfigResponse` // wire shape. Top-level domain keys are mapped camelCase→snake_case generically, // so this route does not enumerate the config domains; values pass through -// unchanged except `providers`, whose credentials are redacted to `has_api_key` -// (the only domain-specific transform). Pure projection: no service calls. +// unchanged except `providers`, whose credentials are redacted to `has_api_key`, +// and `models`, which drops the internal `__secondary__` derived entry (the +// only domain-specific transforms). Pure projection: no service calls. // --------------------------------------------------------------------------- function toConfigResponse(resolved: Record): ConfigResponse { const wire: Record = {}; for (const [domain, value] of Object.entries(resolved)) { - wire[camelToSnake(domain)] = domain === 'providers' ? toProviderResponses(value) : value; + wire[camelToSnake(domain)] = + domain === 'providers' + ? toProviderResponses(value) + : domain === 'models' + ? withoutDerivedSecondaryEntry(value) + : value; } // v1 wire echo: surface `yolo` as a derived boolean of the effective default // permission mode. `yolo` is not a config domain; it is computed here so the @@ -157,6 +169,20 @@ interface ProviderLike { readonly oauth?: unknown; } +/** + * The `models` effective view carries the synthesized `__secondary__` derived + * entry whenever `[secondary_model]` has patch fields. It is an internal + * routing artifact (hidden from the `GET /models` picker the same way) and + * can never persist — the overlay's `strip` removes it from `models` writes — + * so keep it off the wire here too. + */ +function withoutDerivedSecondaryEntry(value: unknown): unknown { + if (!isPlainObject(value) || !(SECONDARY_DERIVED_MODEL_ID in value)) return value; + const out: Record = { ...value }; + delete out[SECONDARY_DERIVED_MODEL_ID]; + return out; +} + function toProviderResponses(value: unknown): Record { const result: Record = {}; if (!isPlainObject(value)) return result; diff --git a/packages/kap-server/test/config.test.ts b/packages/kap-server/test/config.test.ts index 2ccced1839..42d8e89086 100644 --- a/packages/kap-server/test/config.test.ts +++ b/packages/kap-server/test/config.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -15,7 +15,7 @@ interface Envelope { request_id: string; } -describe('server-v2 /api/v1/config default_permission_mode + yolo', () => { +describe('server-v2 /api/v1/config', () => { let server: RunningServer | undefined; let home: string | undefined; let base: string; @@ -96,4 +96,37 @@ describe('server-v2 /api/v1/config default_permission_mode + yolo', () => { expect(after.default_permission_mode).toBe('auto'); expect(after.yolo).toBe(false); }); + + it('POST secondary_model persists [secondary_model] and echoes it on GET', async () => { + await boot(); + const cfg = await patchConfig({ + secondary_model: { model: 'k2-test', default_effort: 'high' }, + }); + expect(cfg.secondary_model).toEqual({ model: 'k2-test', defaultEffort: 'high' }); + + const after = await getConfig(); + expect(after.secondary_model).toEqual({ model: 'k2-test', defaultEffort: 'high' }); + + const toml = await readFile(join(home as string, 'config.toml'), 'utf-8'); + expect(toml).toContain('[secondary_model]'); + expect(toml).toContain('model = "k2-test"'); + expect(toml).toContain('default_effort = "high"'); + }); + + it('GET hides the synthesized __secondary__ derived entry from models', async () => { + await boot('[models.k2-test]\nprovider = "example"\nmodel = "example-model"\n'); + // `default_effort` is a patch field, so the overlay synthesizes the + // `__secondary__` derived entry into the effective `models` view. + const cfg = await patchConfig({ + secondary_model: { model: 'k2-test', default_effort: 'high' }, + }); + const models = cfg.models as Record; + expect(models['k2-test']).toBeDefined(); + expect(models['__secondary__']).toBeUndefined(); + + const after = await getConfig(); + const afterModels = after.models as Record; + expect(afterModels['k2-test']).toBeDefined(); + expect(afterModels['__secondary__']).toBeUndefined(); + }); }); From 086769bfadf1c86ba0569f16315010ffc77344f0 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 16:47:43 +0800 Subject: [PATCH 08/11] chore: drop the changelog entry for the experimental secondary-model API (#2243) The secondary-model feature is still experimental (gated by KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL and ignored by the interactive TUI), so the config API support merged in #2228 should not produce a user-facing changelog entry yet. --- .changeset/config-api-secondary-model.md | 5 ----- 1 file changed, 5 deletions(-) delete mode 100644 .changeset/config-api-secondary-model.md diff --git a/.changeset/config-api-secondary-model.md b/.changeset/config-api-secondary-model.md deleted file mode 100644 index 083a0a7cd2..0000000000 --- a/.changeset/config-api-secondary-model.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Allow updating the subagent secondary model through the configuration API. From a77ee0382965720f7ede523de1f6788bd4422df8 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Mon, 27 Jul 2026 16:55:41 +0800 Subject: [PATCH 09/11] feat(agent-core-v2): add hostIdentity domain for host-overridable system prompt identity (#2144) * feat(agent-core-v2): add hostIdentity domain for host-overridable system prompt identity - add app-scope hostIdentity domain (L3) with productName / replyStyleGuide overrides and hostIdentitySeed for composition roots - render ${product_name} and ${reply_style_guide} in the base system prompt, falling back to CLI defaults when the host provides no override - seed the variables from AgentProfileService via IHostIdentity - expose hostIdentity on kap-server's ServerStartOptions * chore: add changeset for host identity system prompt * fix(agent-core-v2): adapt hostIdentity registration to ScopeActivation The DI refactor on main removed _base/di/extensions (InstantiationType); register with ScopeActivation.OnScopeCreated instead, and regenerate the state manifest for the new SystemPromptContext fields. --------- Co-authored-by: liruifengv --- .changeset/host-identity-system-prompt.md | 5 ++ .../agent-core-v2/docs/state-manifest.d.ts | 6 +- .../scripts/check-domain-layers.mjs | 1 + .../src/agent/profile/profileService.ts | 4 ++ .../agentProfileCatalog.ts | 2 + .../app/agentProfileCatalog/profile-shared.ts | 13 ++++- .../src/app/agentProfileCatalog/system.md | 4 +- .../src/app/hostIdentity/hostIdentity.ts | 56 +++++++++++++++++++ packages/agent-core-v2/src/index.ts | 1 + .../test/agent/profile/profileOps.test.ts | 2 + .../profile-shared.test.ts | 32 +++++++++++ packages/kap-server/src/start.ts | 11 ++++ 12 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 .changeset/host-identity-system-prompt.md create mode 100644 packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts diff --git a/.changeset/host-identity-system-prompt.md b/.changeset/host-identity-system-prompt.md new file mode 100644 index 0000000000..aec1a9deff --- /dev/null +++ b/.changeset/host-identity-system-prompt.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/agent-core-v2": patch +--- + +Let embedding hosts customize the agent's product name and reply-style guidance in the system prompt when starting the server. diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index 6c6e8ae80c..90050ef3ba 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -200,6 +200,8 @@ export interface SessionStateSnapshot { readonly now?: string; readonly skills?: string; readonly skillActive?: boolean; + readonly productName?: string; + readonly replyStyleGuide?: string; [key: string]: unknown; }) => string; readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { @@ -264,6 +266,8 @@ export interface SessionStateSnapshot { readonly now?: string; readonly skills?: string; readonly skillActive?: boolean; + readonly productName?: string; + readonly replyStyleGuide?: string; [key: string]: unknown; }) => string; readonly promptPrefix?: (ctx: /* AgentProfilePromptPrefixContext — packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts */ { @@ -1006,7 +1010,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map 0 ? `${shellName} (\`${shellPath}\`)` : '', diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md index fe939693a8..452bc9128d 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md @@ -1,4 +1,4 @@ -You are Kimi Code CLI, an interactive general AI agent running on a user's computer. +You are ${product_name}, an interactive general AI agent running on a user's computer. Your primary goal is to help users with software engineering tasks by taking action — use the tools available to you to make real changes on the user's system. You should also answer questions when asked. Always adhere strictly to the following system instructions and the user's requirements. @@ -18,7 +18,7 @@ When handling the user's request, if it involves creating, modifying, or running When a dedicated tool fits the job, reach for it before raw shell: `Read` a known path, `Glob` to find files by name, and `Grep` to search file contents. These resolve paths through the workspace access policy and cap their output, so they keep large raw dumps out of the conversation. -Your text replies render as Markdown in the user's terminal. Use light Markdown that reads well there: short paragraphs, `-` bullets for lists, backticks for code, commands, paths, and identifiers, and fenced blocks for multi-line code. Keep structure shallow — avoid deep nesting, large tables, and heavy headings in ordinary replies. Do not use emoji unless the user does first or asks for it. Default to prose; reach for a list only when the content is genuinely a set of items or steps. When you point to a specific code location, cite it as `path/to/file.ts:42` — a precise, consistent reference the user can navigate to. +${reply_style_guide} You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance. This applies especially to read-only investigation — issue independent `Read`, `Grep`, and `Glob` calls in parallel rather than one after another. diff --git a/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts b/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts new file mode 100644 index 0000000000..8720222a72 --- /dev/null +++ b/packages/agent-core-v2/src/app/hostIdentity/hostIdentity.ts @@ -0,0 +1,56 @@ +/** + * `hostIdentity` domain (L3) — runtime identity of the embedding host. + * + * Holds process-level overrides the host product (CLI, desktop, …) injects at + * the composition root: `productName` fills the `${product_name}` slot in the + * base system-prompt template, `replyStyleGuide` replaces the + * `${reply_style_guide}` block (the CLI default describes Markdown rendering + * in a terminal). Composition roots set them through {@link hostIdentitySeed}; + * the registered default carries no overrides, so the template renders its CLI + * defaults. Bound at App scope. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; +import { LifecycleScope, registerScopedService, ScopeActivation, type ScopeSeed } from '#/_base/di/scope'; + +export interface HostIdentityOverrides { + readonly productName?: string; + readonly replyStyleGuide?: string; +} + +export interface IHostIdentity { + readonly _serviceBrand: undefined; + readonly productName?: string; + readonly replyStyleGuide?: string; +} + +export const IHostIdentity: ServiceIdentifier = + createDecorator('hostIdentity'); + +export class HostIdentity implements IHostIdentity { + declare readonly _serviceBrand: undefined; + + constructor( + readonly productName?: string, + readonly replyStyleGuide?: string, + ) {} +} + +export function hostIdentitySeed(overrides: HostIdentityOverrides | undefined): ScopeSeed { + if (overrides === undefined) return []; + if (overrides.productName === undefined && overrides.replyStyleGuide === undefined) return []; + return [ + [ + IHostIdentity as ServiceIdentifier, + new HostIdentity(overrides.productName, overrides.replyStyleGuide), + ], + ]; +} + +registerScopedService( + LifecycleScope.App, + IHostIdentity, + HostIdentity, + ScopeActivation.OnScopeCreated, + 'hostIdentity', +); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index c78cd2f213..fd5732a86e 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -174,6 +174,7 @@ export * from '#/app/agentFileCatalog/configSection'; export * from '#/app/agentFileCatalog/agentProfileSource'; export * from '#/app/agentFileCatalog/agentCatalogRuntimeOptions'; export * from '#/app/agentFileCatalog/userFileAgentSource'; +export * from '#/app/hostIdentity/hostIdentity'; export * from '#/app/plugin/types'; export * from '#/app/plugin/commands'; export * from '#/app/plugin/manifest'; diff --git a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts index ae6a19847d..13a4d6d44f 100644 --- a/packages/agent-core-v2/test/agent/profile/profileOps.test.ts +++ b/packages/agent-core-v2/test/agent/profile/profileOps.test.ts @@ -20,6 +20,7 @@ import { IAgentStateService } from '#/agent/state/agentState'; import { AgentStateService } from '#/agent/state/agentStateService'; import { IHostEnvironment } from '#/os/interface/hostEnvironment'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { IHostIdentity } from '#/app/hostIdentity/hostIdentity'; import { AppendLogStore } from '#/persistence/backends/node-fs/appendLogStore'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; @@ -210,6 +211,7 @@ function buildHost(key: string): { host.stub(IProtocolAdapterRegistry, createProtocolRegistryStub()); host.stub(IHostEnvironment, stubUnused()); host.stub(IHostFileSystem, stubUnused()); + host.stub(IHostIdentity, stubUnused()); host.stub(IBootstrapService, stubUnused()); host.stub(ISessionContext, createSessionContextStub()); host.stub(ISessionWorkspaceContext, stubUnused()); diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts index a8bd9d6e63..b45780cced 100644 --- a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts @@ -84,6 +84,23 @@ describe('systemPromptVars', () => { ).toContain('IMPORTANT: You are on Windows'); expect(systemPromptVars({ osKind: 'macOS' }, { skillActive: true })['windows_notes']).toBe(''); }); + + it('defaults host-identity variables to the CLI text', () => { + const vars = systemPromptVars({}, { skillActive: true }); + + expect(vars['product_name']).toBe('Kimi Code CLI'); + expect(vars['reply_style_guide']).toContain("render as Markdown in the user's terminal"); + }); + + it('lets the context override host-identity variables', () => { + const vars = systemPromptVars( + { productName: 'Kimi Desktop', replyStyleGuide: 'GUI_STYLE' }, + { skillActive: true }, + ); + + expect(vars['product_name']).toBe('Kimi Desktop'); + expect(vars['reply_style_guide']).toBe('GUI_STYLE'); + }); }); describe('renderPromptTemplate', () => { @@ -183,4 +200,19 @@ describe('renderSystemPrompt', () => { expect(prompt).not.toMatch(/\$\{[A-Za-z_][A-Za-z0-9_]*\}/); }); + + it('renders the host identity from the context, defaulting to the CLI text', () => { + const fallback = renderSystemPrompt('', {}, { skillActive: true }); + expect(fallback).toContain('You are Kimi Code CLI,'); + expect(fallback).toContain("render as Markdown in the user's terminal"); + + const overridden = renderSystemPrompt( + '', + { productName: 'Kimi Desktop', replyStyleGuide: 'GUI_STYLE' }, + { skillActive: true }, + ); + expect(overridden).toContain('You are Kimi Desktop,'); + expect(overridden).toContain('GUI_STYLE'); + expect(overridden).not.toContain('Kimi Code CLI'); + }); }); diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index fb6c37fad9..6fe6b2626d 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -9,6 +9,7 @@ import { bootstrap, + hostIdentitySeed, hostRequestHeadersSeed, IConfigService, IProviderDiscoveryService, @@ -18,6 +19,7 @@ import { resolveKimiHome, resolveLoggingConfig, skillCatalogRuntimeOptionsSeed, + type HostIdentityOverrides, type Scope, type ScopeSeed, } from '@moonshot-ai/agent-core-v2'; @@ -104,6 +106,14 @@ export interface ServerStartOptions { readonly rpcToken?: string; /** Extra scope seeds applied at bootstrap (e.g. a host-provided `ISessionModelResolver`). */ readonly seeds?: ScopeSeed; + /** + * Host product identity injected into the base system prompt: `productName` + * fills the `${product_name}` slot, `replyStyleGuide` replaces the + * `${reply_style_guide}` block. Applied to every agent the server hosts — for + * embedding hosts (e.g. a desktop app), not per-session use. Defaults render + * the CLI text. + */ + readonly hostIdentity?: HostIdentityOverrides; /** * Explicit skill directories for this process (v1's SDK `skillDirs`): when * non-empty, default user / project skill discovery is skipped and these @@ -210,6 +220,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise Date: Mon, 27 Jul 2026 17:00:42 +0800 Subject: [PATCH 10/11] feat(cli): add plugin quota and update notices (#2147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cli): add plugin quota and update notices - Show "Note: This plugin consumes your quota." after installing quota-consuming official plugins (currently Kimi Datasource). - Show a one-time update notice after invoking an outdated plugin (a plugin MCP tool call or a /: turn); the last notified version is persisted so each new marketplace version reminds once. - Skip the third-party trust prompt for loopback sources that mirror the official plugin CDN path, so the dev plugin marketplace no longer prompts when installing official plugins. * feat(cli): report plugin update notices at turn end Buffer plugin MCP tool usage during the turn and report it together with plugin command usage when the turn's output has fully ended, instead of firing the check mid-turn at tool result time. Cancelled turns no longer trigger the notice. * fix(cli): refresh plugin MCP map on miss and serialize notice writes Address review findings on the plugin update notifier: - The memoized MCP server-to-plugin map is reused across /reload, /new, and session switches, so plugins installed or enabled later in the same app run never resolved. Refresh the map once on a lookup miss, and never pin an empty map when there is no session. - Concurrent notices (a turn that used two outdated plugins) raced on the read-modify-write cycle of the notice state file and could drop each other's entries. Serialize checks through a promise queue so each notified version is persisted exactly once. * fix(cli): gate plugin notices on official provenance and survive tool-name truncation Address review findings: - The quota note and the update notice keyed on the plugin id alone, so a local/GitHub fork reusing a billed plugin's manifest id was treated as the official build. Both now require official provenance (a zip install from the official CDN plugin path or its loopback dev mirror) via a shared isOfficialPluginInstall check. - Resolving plugin MCP tools by splitting on the '__' separator broke for qualified names core truncates to 64 chars, which can cut the separator. Match known server names by longest prefix with a name boundary instead, which survives truncation as long as the server part itself is intact. * revert(cli): drop the dev marketplace trust relaxation The loopback carve-out let any local service bypass the third-party trust prompt by serving a zip under the official path shape, which does not prove official provenance (review P1). Revert to the single rule — only https://code.kimi.com/kimi-code/plugins/official/* is a trusted official source — and restore the stock dev marketplace server. Installing official plugins from the dev marketplace shows the trust prompt again. * fix(cli): restrict update notices to the official catalog and settle tests - Skip the update check when the loaded marketplace is not the default official catalog, so a custom KIMI_CODE_PLUGIN_MARKETPLACE_URL can no longer produce a notice that claims to come from the Official Marketplace. - Return a never-rejecting promise from the notifier entry points so tests await the serialized queue directly instead of relying on zero-delay timers for ordering. --- .changeset/plugin-quota-note.md | 5 + .changeset/plugin-update-notice.md | 5 + apps/kimi-code/src/constant/app.ts | 4 + apps/kimi-code/src/tui/commands/plugins.ts | 14 +- .../tui/controllers/plugin-update-notifier.ts | 208 ++++++++++++ .../tui/controllers/session-event-handler.ts | 47 ++- .../src/tui/utils/plugin-source-label.ts | 14 + apps/kimi-code/src/utils/paths.ts | 12 + .../src/utils/plugin-update-notice-state.ts | 67 ++++ .../dialogs/plugins-selector.test.ts | 41 ++- .../plugin-update-notifier.test.ts | 301 ++++++++++++++++++ ...ssion-event-handler-plugin-updates.test.ts | 186 +++++++++++ .../test/tui/kimi-tui-message-flow.test.ts | 70 ++++ docs/en/customization/plugins.md | 4 +- docs/zh/customization/plugins.md | 4 +- 15 files changed, 972 insertions(+), 10 deletions(-) create mode 100644 .changeset/plugin-quota-note.md create mode 100644 .changeset/plugin-update-notice.md create mode 100644 apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts create mode 100644 apps/kimi-code/src/utils/plugin-update-notice-state.ts create mode 100644 apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts create mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts diff --git a/.changeset/plugin-quota-note.md b/.changeset/plugin-quota-note.md new file mode 100644 index 0000000000..47be2e0ab0 --- /dev/null +++ b/.changeset/plugin-quota-note.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show a quota consumption note after installing official plugins that bill against plan quota (such as Kimi Datasource). diff --git a/.changeset/plugin-update-notice.md b/.changeset/plugin-update-notice.md new file mode 100644 index 0000000000..c08558775b --- /dev/null +++ b/.changeset/plugin-update-notice.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Show an update notice when a turn that used an outdated plugin ends and the Official Marketplace has a newer version; each new version is announced once. Run /plugins to install the latest version. diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index f09cc600ae..59aa187974 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -48,6 +48,7 @@ export const KIMI_CODE_UPDATE_STATE_FILE_NAME = 'latest.json'; export const KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME = 'install.json'; export const KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME = 'install.lock'; export const KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME = 'rollout.log'; +export const KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME = 'plugin-notices.json'; export const KIMI_CODE_INPUT_HISTORY_DIR_NAME = 'user-history'; export const KIMI_CODE_BANNER_DIR_NAME = 'banner'; export const KIMI_CODE_BANNER_STATE_FILE_NAME = 'state.json'; @@ -79,6 +80,9 @@ export const KIMI_CODE_CDN_LATEST_JSON_URL = `${KIMI_CODE_CDN_BASE}/latest.json` export const KIMI_CODE_TIPS_BANNER_URL = 'https://cdn.kimi.com/kimi-code-tips/tips.json'; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL = `${KIMI_CODE_CDN_BASE}/plugins/marketplace.json`; export const KIMI_CODE_PLUGIN_MARKETPLACE_URL_ENV = 'KIMI_CODE_PLUGIN_MARKETPLACE_URL'; +// Official plugins whose usage bills against the user's plan quota. Installing +// one of these shows a quota note after the install result. +export const QUOTA_CONSUMING_PLUGIN_IDS: readonly string[] = ['kimi-datasource']; export const KIMI_CODE_INSTALL_SH_URL = `${KIMI_CODE_CDN_BASE}/install.sh`; export const KIMI_CODE_INSTALL_PS1_URL = `${KIMI_CODE_CDN_BASE}/install.ps1`; // Official download page, referenced by prompt copy that steers users away diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index b69f0724a9..51bd3515a0 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -20,7 +20,12 @@ import { } from '../components/messages/plugins-status-panel'; import { UsagePanelComponent } from '../components/messages/usage-panel'; import { formatErrorMessage } from '../utils/event-payload'; -import { formatPluginSourceLabel, isOfficialPluginSource } from '../utils/plugin-source-label'; +import { + formatPluginSourceLabel, + isOfficialPluginInstall, + isOfficialPluginSource, +} from '../utils/plugin-source-label'; +import { QUOTA_CONSUMING_PLUGIN_IDS } from '#/constant/app'; import { loadPluginMarketplace } from '#/utils/plugin-marketplace'; import { openUrl } from '#/utils/open-url'; import type { SlashCommandHost } from './dispatch'; @@ -492,6 +497,8 @@ async function installPluginFromSource( const PLUGIN_RELOAD_HINT = 'Run /new or /reload to apply plugin changes.'; +const PLUGIN_QUOTA_NOTE = 'Note: This plugin consumes your quota.'; + function showPluginInstallResult( host: SlashCommandHost, beforeList: readonly PluginSummary[], @@ -506,6 +513,11 @@ function showPluginInstallResult( const action = describeInstallAction(previous, summary); host.showStatus(`${action} (${summary.id}).${mcpHint}`); host.showStatus(PLUGIN_RELOAD_HINT, 'warning'); + // Gate on provenance, not just the id: a local/GitHub fork whose manifest + // reuses a billed plugin's id is not the official quota-consuming build. + if (QUOTA_CONSUMING_PLUGIN_IDS.includes(summary.id) && isOfficialPluginInstall(summary)) { + host.showStatus(PLUGIN_QUOTA_NOTE, 'warning'); + } } function describeInstallAction( diff --git a/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts new file mode 100644 index 0000000000..ab6d728079 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/plugin-update-notifier.ts @@ -0,0 +1,208 @@ +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + computeUpdateStatus, + loadPluginMarketplace, + type PluginMarketplace, +} from '#/utils/plugin-marketplace'; +import { + readPluginUpdateNoticeState, + writePluginUpdateNoticeState, +} from '#/utils/plugin-update-notice-state'; +import { isOfficialPluginInstall } from '../utils/plugin-source-label'; + +/** + * The slice of the SDK session the notifier reads. Structurally satisfied by + * the full SDK `Session`, and easy to fake in tests. + */ +export interface PluginUpdateNotifierSession { + listMcpServers(): Promise; + listPlugins(): Promise; +} + +export interface PluginUpdateNotifierDeps { + readonly getSession: () => PluginUpdateNotifierSession | undefined; + readonly workDir: string; + readonly notify: (message: string) => void; + /** Overridable for tests; defaults to the shared marketplace loader. */ + readonly loadMarketplace?: () => Promise; + /** Overridable for tests; defaults to the updates dir under the data dir. */ + readonly stateFile?: string; +} + +const MCP_TOOL_NAME_PREFIX = 'mcp__'; +const PLUGIN_MCP_TOOL_NAME_PREFIX = `${MCP_TOOL_NAME_PREFIX}plugin-`; +// Plugin MCP servers run under the runtime name `plugin-:` +// (pluginMcpRuntimeName in packages/agent-core/src/plugin/manager.ts). +const PLUGIN_MCP_RUNTIME_NAME = /^plugin-([a-z0-9][a-z0-9_-]{0,63}):/; + +/** Cheap name check for plugin-provided MCP tools (`mcp__plugin-…`). */ +export function isPluginMcpToolName(toolName: string): boolean { + return toolName.startsWith(PLUGIN_MCP_TOOL_NAME_PREFIX); +} + +/** + * Mirror of sanitizeMcpNamePart in packages/agent-core/src/mcp/tool-naming.ts. + * MCP tool names on the wire carry the sanitized server name; the collapse + * step guarantees the `__` separator never appears inside a name part. + */ +function sanitizeMcpServerName(name: string): string { + return name.replaceAll(/[^a-zA-Z0-9_-]/g, '_').replaceAll(/_+/g, '_'); +} + +/** + * Find the plugin behind a qualified MCP tool name by longest-prefix match + * against known server names. Prefix matching (rather than splitting on the + * `__` separator) survives core's 64-char truncation, which can cut the + * separator before appending the hash suffix; the boundary check keeps a + * shorter server name from matching another server's name, and longest match + * wins when one server name is a prefix of another. A name truncated inside + * the server part itself cannot be attributed reliably and stays unresolved. + */ +function matchPluginByToolName( + toolName: string, + serverPluginIds: Map, +): string | undefined { + let best: string | undefined; + let bestLength = 0; + for (const [serverName, pluginId] of serverPluginIds) { + const prefix = `${MCP_TOOL_NAME_PREFIX}${serverName}`; + if (!toolName.startsWith(prefix)) continue; + const boundary = toolName.charAt(prefix.length); + if (boundary !== '' && boundary !== '_') continue; + if (prefix.length > bestLength) { + best = pluginId; + bestLength = prefix.length; + } + } + return best; +} + +/** + * Shows a one-time "update detected" notice for outdated plugins. Callers + * report completed plugin usage (a plugin MCP tool name, or the plugin id of + * a `/:` turn — both reported once the turn's output has + * ended); the notifier checks the marketplace and persists the last notified + * version, so a plugin is re-notified only when the marketplace advertises a + * newer version than the one already shown. + * + * Entry points are fire-and-forget in production (never reject — the notice + * is a background nicety and any failure, e.g. an offline marketplace, is + * swallowed) and return an awaitable promise so tests can settle the queue + * deterministically. + */ +export class PluginUpdateNotifier { + private marketplacePromise: Promise | undefined; + private mcpServerPluginIds: Map | undefined; + private readonly inFlight = new Set(); + private queue: Promise = Promise.resolve(); + + constructor(private readonly deps: PluginUpdateNotifierDeps) {} + + handleMcpToolCompleted(toolName: string): Promise { + // Cheap bail before touching the RPC layer — most tools are not MCP tools, + // let alone plugin ones. + if (!isPluginMcpToolName(toolName)) return Promise.resolve(); + return this.resolvePluginId(toolName) + .then((pluginId) => { + if (pluginId !== undefined) return this.enqueue(pluginId); + return undefined; + }) + .catch(() => {}); + } + + handlePluginCommandCompleted(pluginId: string): Promise { + return this.enqueue(pluginId); + } + + private enqueue(pluginId: string): Promise { + // Serialize the read-modify-write cycle on the notice state file: two + // concurrent checks (e.g. a turn that used two outdated plugins) would + // otherwise read the same snapshot and the last write would drop the + // other plugin's entry. + this.queue = this.queue.then(() => this.checkAndNotify(pluginId)).catch(() => {}); + return this.queue; + } + + private async resolvePluginId(toolName: string): Promise { + const hit = matchPluginByToolName(toolName, await this.getMcpServerPluginIds()); + if (hit !== undefined) return hit; + // The map is memoized, but this notifier is reused across /reload, /new, + // and session switches, so plugins installed or enabled later in the same + // app run are missing from it. Refresh once on a miss before giving up. + return matchPluginByToolName(toolName, await this.loadMcpServerPluginIds()); + } + + private async getMcpServerPluginIds(): Promise> { + if (this.mcpServerPluginIds !== undefined) return this.mcpServerPluginIds; + return this.loadMcpServerPluginIds(); + } + + private async loadMcpServerPluginIds(): Promise> { + const map = new Map(); + const session = this.deps.getSession(); + // Without a session there is nothing to list; leave the cache unset so + // the next lookup retries instead of pinning an empty map. + if (session === undefined) return map; + const servers = await session.listMcpServers(); + for (const server of servers) { + const match = PLUGIN_MCP_RUNTIME_NAME.exec(server.name); + if (match?.[1] !== undefined) { + map.set(sanitizeMcpServerName(server.name), match[1]); + } + } + this.mcpServerPluginIds = map; + return map; + } + + private async checkAndNotify(pluginId: string): Promise { + if (this.inFlight.has(pluginId)) return; + this.inFlight.add(pluginId); + try { + const session = this.deps.getSession(); + if (session === undefined) return; + const marketplace = await this.loadCatalog(); + // Only the default official catalog can back an "Official Marketplace" + // notice — a custom catalog (KIMI_CODE_PLUGIN_MARKETPLACE_URL) may + // advertise anything under any id. + if (marketplace.source !== KIMI_CODE_PLUGIN_MARKETPLACE_URL) return; + const entry = marketplace.plugins.find((plugin) => plugin.id === pluginId); + if (entry === undefined) return; + const installed = (await session.listPlugins()).find((plugin) => plugin.id === pluginId); + if (installed === undefined) return; + // Only official installs are tracked against the Official Marketplace — + // a local/GitHub fork that happens to share a catalog id is not it. + if (!isOfficialPluginInstall(installed)) return; + const status = computeUpdateStatus(entry.version, installed.version, true); + if (status.kind !== 'update') return; + const state = await readPluginUpdateNoticeState(this.deps.stateFile); + if (state.notified[pluginId] === status.latest) return; + this.deps.notify( + `Update detected: ${installed.displayName} ${status.latest} is available. ` + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + await writePluginUpdateNoticeState( + { ...state, notified: { ...state.notified, [pluginId]: status.latest } }, + this.deps.stateFile, + ); + } finally { + this.inFlight.delete(pluginId); + } + } + + private loadCatalog(): Promise { + // Cached for the app run; a failed fetch is retried on the next invocation. + this.marketplacePromise ??= this.loadMarketplace().catch((error: unknown) => { + this.marketplacePromise = undefined; + throw error; + }); + return this.marketplacePromise; + } + + private loadMarketplace(): Promise { + const load = this.deps.loadMarketplace; + if (load !== undefined) return load(); + return loadPluginMarketplace({ workDir: this.deps.workDir }); + } +} diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 3a8b5ada3d..b82f919610 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -73,6 +73,7 @@ import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; import type { BtwPanelController } from './btw-panel'; +import { isPluginMcpToolName, PluginUpdateNotifier } from './plugin-update-notifier'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; import { SubAgentEventHandler } from './subagent-event-handler'; @@ -119,8 +120,12 @@ export interface SessionEventHost { export class SessionEventHandler { readonly subAgentEventHandler: SubAgentEventHandler; + private readonly pluginUpdateNotifier: PluginUpdateNotifier; - constructor(private readonly host: SessionEventHost) { + constructor( + private readonly host: SessionEventHost, + pluginUpdateNotifier?: PluginUpdateNotifier, + ) { this.subAgentEventHandler = new SubAgentEventHandler(host, { backgroundTasks: this.backgroundTasks, backgroundTaskTranscriptedTerminal: this.backgroundTaskTranscriptedTerminal, @@ -128,6 +133,15 @@ export class SessionEventHandler { this.syncBackgroundTaskBadge(); }, }); + this.pluginUpdateNotifier = + pluginUpdateNotifier ?? + new PluginUpdateNotifier({ + getSession: () => this.host.session, + workDir: host.state.appState.workDir, + notify: (message) => { + this.host.showStatus(message, 'warning'); + }, + }); } // Runtime state – owned by this handler, reset between sessions. @@ -142,6 +156,8 @@ export class SessionEventHandler { private goalCompletionAwaitingClear = false; private goalCompletionTurnEnded = false; private currentTurnHasAssistantText = false; + private pluginCommandTurns: Map = new Map(); + private pluginMcpToolsUsedInTurn: Set = new Set(); private pendingModelBlockedFallback: GoalChange | undefined; private queuedGoalPromotionPending = false; private queuedGoalPromotionInFlight = false; @@ -158,6 +174,8 @@ export class SessionEventHandler { this.goalCompletionAwaitingClear = false; this.goalCompletionTurnEnded = false; this.currentTurnHasAssistantText = false; + this.pluginCommandTurns.clear(); + this.pluginMcpToolsUsedInTurn.clear(); this.pendingModelBlockedFallback = undefined; this.queuedGoalPromotionPending = false; this.queuedGoalPromotionInFlight = false; @@ -293,9 +311,11 @@ export class SessionEventHandler { // Private handlers // --------------------------------------------------------------------------- - private handleTurnBegin(_event: TurnStartedEvent): void { - void _event; + private handleTurnBegin(event: TurnStartedEvent): void { this.currentTurnHasAssistantText = false; + if (event.origin?.kind === 'plugin_command') { + this.pluginCommandTurns.set(String(event.turnId), event.origin.pluginId); + } this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); this.host.streamingUI.setStep(0); @@ -348,6 +368,22 @@ export class SessionEventHandler { this.renderPendingModelBlockedFallback(); this.currentTurnHasAssistantText = false; this.goalCompletionTurnEnded = true; + // Plugin usage is reported once the whole turn's output has ended — but a + // cancelled turn cut the output short, so skip the notice there. + const reportPluginUsage = event.reason !== 'cancelled'; + const pluginCommandPluginId = this.pluginCommandTurns.get(String(event.turnId)); + if (pluginCommandPluginId !== undefined) { + this.pluginCommandTurns.delete(String(event.turnId)); + if (reportPluginUsage) { + void this.pluginUpdateNotifier.handlePluginCommandCompleted(pluginCommandPluginId); + } + } + if (reportPluginUsage) { + for (const toolName of this.pluginMcpToolsUsedInTurn) { + void this.pluginUpdateNotifier.handleMcpToolCompleted(toolName); + } + } + this.pluginMcpToolsUsedInTurn.clear(); this.scheduleQueuedGoalPromotion(); } @@ -582,6 +618,11 @@ export class SessionEventHandler { synthetic: event.synthetic, }; const matchedCall = streamingUI.completeToolResult(event.toolCallId, resultData); + if (matchedCall !== undefined && isPluginMcpToolName(matchedCall.name)) { + // Buffer plugin MCP usage for the turn; the update notice fires once the + // whole turn's output has ended (see handleTurnEnd). + this.pluginMcpToolsUsedInTurn.add(matchedCall.name); + } this.subAgentEventHandler.handleAgentSwarmToolResult( event.toolCallId, resultData, diff --git a/apps/kimi-code/src/tui/utils/plugin-source-label.ts b/apps/kimi-code/src/tui/utils/plugin-source-label.ts index d475313ae8..5a902db7de 100644 --- a/apps/kimi-code/src/tui/utils/plugin-source-label.ts +++ b/apps/kimi-code/src/tui/utils/plugin-source-label.ts @@ -70,6 +70,20 @@ export function isOfficialPluginSource(source: string): boolean { } } +/** + * Returns true when an installed plugin provably came from a trusted official + * source — a zip download under the official CDN plugin path. Local paths, + * GitHub repos, and third-party URLs do not qualify, even when their manifest + * id matches an official plugin. + */ +export function isOfficialPluginInstall(plugin: PluginSummary): boolean { + return ( + plugin.source === 'zip-url' && + plugin.originalSource !== undefined && + isOfficialPluginSource(plugin.originalSource) + ); +} + function hostFromUrl(raw: string): string | undefined { try { const url = new URL(raw); diff --git a/apps/kimi-code/src/utils/paths.ts b/apps/kimi-code/src/utils/paths.ts index 83b681f1ce..2127726ccd 100644 --- a/apps/kimi-code/src/utils/paths.ts +++ b/apps/kimi-code/src/utils/paths.ts @@ -18,6 +18,7 @@ import { KIMI_CODE_HOME_ENV, KIMI_CODE_INPUT_HISTORY_DIR_NAME, KIMI_CODE_LOG_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_LOCK_FILE_NAME, KIMI_CODE_UPDATE_INSTALL_STATE_FILE_NAME, KIMI_CODE_UPDATE_DIR_NAME, @@ -87,6 +88,17 @@ export function getUpdateRolloutLogFile(): string { return join(getDataDir(), KIMI_CODE_UPDATE_DIR_NAME, KIMI_CODE_UPDATE_ROLLOUT_LOG_FILE_NAME); } +/** + * Return the plugin update notice state file: `/updates/plugin-notices.json`. + */ +export function getPluginUpdateNoticeStateFile(): string { + return join( + getDataDir(), + KIMI_CODE_UPDATE_DIR_NAME, + KIMI_CODE_PLUGIN_UPDATE_NOTICE_STATE_FILE_NAME, + ); +} + /** * Return the banner display state file: `/cache/banner/state.json`. */ diff --git a/apps/kimi-code/src/utils/plugin-update-notice-state.ts b/apps/kimi-code/src/utils/plugin-update-notice-state.ts new file mode 100644 index 0000000000..7baebbe779 --- /dev/null +++ b/apps/kimi-code/src/utils/plugin-update-notice-state.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +import { getPluginUpdateNoticeStateFile } from '#/utils/paths'; +import { readJsonFile, writeJsonFile } from '#/utils/persistence'; + +/** + * Records, per plugin, the newest marketplace version an update notice was + * already shown for. A plugin is re-notified only when the marketplace + * advertises a version different from the recorded one. + */ +export type PluginUpdateNoticeState = { + version: 1; + /** pluginId -> latest marketplace version already notified. */ + notified: Record; +}; + +const PluginUpdateNoticeStateSchema = z.preprocess( + (value) => { + if (typeof value !== 'object' || value === null) return value; + const notified = (value as { notified?: unknown }).notified; + if (typeof notified !== 'object' || notified === null) { + return { ...(value as Record), notified: {} }; + } + + const normalizedNotified: Record = {}; + for (const [key, record] of Object.entries(notified)) { + if (key.length === 0 || typeof record !== 'string' || record.length === 0) continue; + normalizedNotified[key] = record; + } + + return { ...(value as Record), notified: normalizedNotified }; + }, + z + .object({ + version: z.literal(1), + notified: z.record(z.string().min(1), z.string().min(1)), + }) + .strict(), +); + +export function emptyPluginUpdateNoticeState(): PluginUpdateNoticeState { + return { + version: 1, + notified: {}, + }; +} + +export async function readPluginUpdateNoticeState( + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + try { + return await readJsonFile( + filePath, + PluginUpdateNoticeStateSchema, + emptyPluginUpdateNoticeState(), + ); + } catch { + return emptyPluginUpdateNoticeState(); + } +} + +export async function writePluginUpdateNoticeState( + value: PluginUpdateNoticeState, + filePath: string = getPluginUpdateNoticeStateFile(), +): Promise { + await writeJsonFile(filePath, PluginUpdateNoticeStateSchema, value); +} diff --git a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts index 4c379820ae..38ee3a67ec 100644 --- a/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts +++ b/apps/kimi-code/test/tui/components/dialogs/plugins-selector.test.ts @@ -13,7 +13,7 @@ import { } from '#/tui/components/dialogs/plugins-selector'; import { currentTheme } from '#/tui/theme'; import { darkColors, lightColors } from '#/tui/theme/colors'; -import { isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; +import { isOfficialPluginInstall, isOfficialPluginSource, pluginTrustLabel } from '#/tui/utils/plugin-source-label'; const ANSI_SGR = /\u001B\[[0-9;]*m/g; @@ -150,14 +150,51 @@ describe('plugins selector dialogs', () => { })).toBe('third-party'); }); + it('recognizes installed plugins by official provenance', () => { + const base = { + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + enabled: true, + state: 'ok' as const, + skillCount: 0, + mcpServerCount: 0, + enabledMcpServerCount: 0, + hookCount: 0, + commandCount: 0, + hasErrors: false, + }; + // Zip installs from the official CDN path. + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(true); + // Same manifest id from a local path, GitHub, a loopback URL, or a + // third-party URL is not the official build. + expect(isOfficialPluginInstall({ ...base, source: 'local-path' })).toBe(false); + expect(isOfficialPluginInstall({ ...base, source: 'github' })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'http://127.0.0.1:58627/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(false); + expect(isOfficialPluginInstall({ + ...base, + source: 'zip-url', + originalSource: 'https://example.test/kimi-code/plugins/official/kimi-datasource.zip', + })).toBe(false); + }); + it('treats only the official Kimi CDN path as a trusted install source', () => { expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip')).toBe(true); // Curated and other Kimi CDN paths are not "official" for the install gate. expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/curated/superpowers.zip')).toBe(false); expect(isOfficialPluginSource('https://code.kimi.com/kimi-code/plugins/foo.zip')).toBe(false); - // Non-Kimi hosts, non-https schemes, local paths, and GitHub sources are unofficial. + // Non-Kimi hosts (loopback included), non-https schemes, local paths, and + // GitHub sources are unofficial. expect(isOfficialPluginSource('https://example.test/kimi-code/plugins/official/x.zip')).toBe(false); expect(isOfficialPluginSource('http://code.kimi.com/kimi-code/plugins/official/x.zip')).toBe(false); + expect(isOfficialPluginSource('http://127.0.0.1:58627/kimi-code/plugins/official/x.zip')).toBe(false); expect(isOfficialPluginSource('./plugins/kimi-datasource')).toBe(false); expect(isOfficialPluginSource('/abs/path/to/plugin')).toBe(false); expect(isOfficialPluginSource('github.com/owner/repo')).toBe(false); diff --git a/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts new file mode 100644 index 0000000000..ca66af33f2 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/plugin-update-notifier.test.ts @@ -0,0 +1,301 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PluginSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { + PluginUpdateNotifier, + type PluginUpdateNotifierSession, +} from '#/tui/controllers/plugin-update-notifier'; +import type { PluginMarketplace } from '#/utils/plugin-marketplace'; +import { readPluginUpdateNoticeState } from '#/utils/plugin-update-notice-state'; + +function makePluginSummary(overrides: Partial = {}): PluginSummary { + return { + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hookCount: 0, + commandCount: 0, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ...overrides, + }; +} + +function makeMarketplaceEntry( + id: string, + displayName: string, + version: string, +): PluginMarketplace['plugins'][number] { + return { + id, + displayName, + source: `https://code.kimi.com/kimi-code/plugins/official/${id}.zip`, + tier: 'official', + version, + }; +} + +function makeMarketplace(version = '3.4.0'): PluginMarketplace { + return { + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', version)], + }; +} + +interface HarnessOptions { + readonly marketplace?: PluginMarketplace; + readonly installed?: readonly PluginSummary[]; + readonly mcpServers?: readonly string[]; + readonly loadMarketplace?: () => Promise; +} + +function makeHarness(options: HarnessOptions = {}) { + const session: PluginUpdateNotifierSession = { + listMcpServers: vi.fn(async () => + (options.mcpServers ?? ['plugin-kimi-datasource:data']).map((name) => ({ name })), + ), + listPlugins: vi.fn(async () => options.installed ?? [makePluginSummary()]), + }; + const notify = vi.fn(); + const loadMarketplace = vi.fn( + options.loadMarketplace ?? (async () => options.marketplace ?? makeMarketplace()), + ); + return { session, notify, loadMarketplace }; +} + +const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool'; +const EXPECTED_MESSAGE = + 'Update detected: Kimi Datasource 3.4.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.'; + +describe('PluginUpdateNotifier', () => { + let tempDir: string; + let stateFile: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'plugin-update-notifier-')); + stateFile = join(tempDir, 'plugin-notices.json'); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function makeNotifier(harness: ReturnType) { + return new PluginUpdateNotifier({ + getSession: () => harness.session, + workDir: tempDir, + notify: harness.notify, + loadMarketplace: harness.loadMarketplace, + stateFile, + }); + } + + it('notifies once after a plugin MCP tool completes, then stays silent for that version', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + + harness.notify.mockClear(); + // Follow-up checks run but hit the persisted "already notified" record + // instead of notifying again. + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).not.toHaveBeenCalled(); + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tool names without touching the session', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted('Bash'); + await notifier.handleMcpToolCompleted('mcp__github__create_issue'); + + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the installed version is up to date', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ version: '3.4.0' })] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listPlugins).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for plugins absent from the marketplace', async () => { + const harness = makeHarness({ installed: [makePluginSummary({ id: 'local-only' })] }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('local-only'); + // No marketplace entry — the check bails before even listing plugins. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify when the catalog is not the official marketplace', async () => { + const harness = makeHarness({ + marketplace: { + source: 'https://example.test/custom-marketplace.json', + plugins: [makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0')], + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + // A custom catalog may advertise anything under any id — the check bails + // before comparing versions. + expect(harness.session.listPlugins).not.toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('does not notify for a same-id fork installed from a local path', async () => { + const harness = makeHarness({ + installed: [ + makePluginSummary({ source: 'local-path', originalSource: undefined }), + ], + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + // Provenance is not official, so the marketplace version is irrelevant. + expect(harness.notify).not.toHaveBeenCalled(); + }); + + it('resolves plugin tools whose qualified name core truncated before the separator', async () => { + // Server part exactly 50 chars: the 64-char truncation cuts the whole + // `__` separator and tool name, leaving `mcp___`. + const serverName = `plugin-kimi-datasource:${'s'.repeat(27)}`; + const sanitized = `plugin-kimi-datasource_${'s'.repeat(27)}`; + expect(`mcp__${sanitized}`.length).toBe(55); + const truncatedToolName = `mcp__${sanitized}_a1b2c3d4`; + + const harness = makeHarness({ mcpServers: [serverName] }); + const notifier = makeNotifier(harness); + + await notifier.handleMcpToolCompleted(truncatedToolName); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('notifies after a plugin command turn ends', async () => { + const harness = makeHarness(); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + // Plugin commands resolve the plugin id directly — no MCP server lookup. + expect(harness.session.listMcpServers).not.toHaveBeenCalled(); + }); + + it('reminds again when the marketplace advertises a newer version', async () => { + const first = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const notifier = makeNotifier(first); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(first.notify).toHaveBeenCalledTimes(1); + + // A new notifier (fresh app run) against the same state file stays silent + // for the already-notified version… + const second = makeHarness({ marketplace: makeMarketplace('3.4.0') }); + const secondNotifier = makeNotifier(second); + await secondNotifier.handlePluginCommandCompleted('kimi-datasource'); + expect(second.notify).not.toHaveBeenCalled(); + + // …but reminds once the marketplace moves to a newer version. + const third = makeHarness({ marketplace: makeMarketplace('3.5.0') }); + const thirdNotifier = makeNotifier(third); + await thirdNotifier.handlePluginCommandCompleted('kimi-datasource'); + expect(third.notify).toHaveBeenCalledWith( + 'Update detected: Kimi Datasource 3.5.0 is available. ' + + 'Run /plugins to install the latest version from the Official Marketplace.', + ); + }); + + it('swallows marketplace failures and retries on the next invocation', async () => { + let attempts = 0; + const harness = makeHarness({ + loadMarketplace: async () => { + attempts += 1; + if (attempts === 1) throw new Error('offline'); + return makeMarketplace(); + }, + }); + const notifier = makeNotifier(harness); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.loadMarketplace).toHaveBeenCalledTimes(1); + expect(harness.notify).not.toHaveBeenCalled(); + + await notifier.handlePluginCommandCompleted('kimi-datasource'); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('refreshes the memoized MCP server map when a lookup misses', async () => { + const harness = makeHarness({ mcpServers: [] }); + let servers: readonly string[] = []; + harness.session.listMcpServers = vi.fn(async () => servers.map((name) => ({ name }))); + const notifier = makeNotifier(harness); + + // The plugin's MCP server is not registered yet (the plugin gets + // installed later in the same app run, applied on /reload or /new). + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.session.listMcpServers).toHaveBeenCalled(); + expect(harness.notify).not.toHaveBeenCalled(); + + // After the reload the new server shows up; the next completion must + // refresh the memoized map instead of silently staying unresolved. + servers = ['plugin-kimi-datasource:data']; + await notifier.handleMcpToolCompleted(DATASOURCE_TOOL); + expect(harness.notify).toHaveBeenCalledWith(EXPECTED_MESSAGE); + }); + + it('keeps every notified plugin when a turn uses two outdated plugins', async () => { + const harness = makeHarness({ + marketplace: { + source: KIMI_CODE_PLUGIN_MARKETPLACE_URL, + plugins: [ + makeMarketplaceEntry('kimi-datasource', 'Kimi Datasource', '3.4.0'), + makeMarketplaceEntry('another-plugin', 'Another Plugin', '2.0.0'), + ], + }, + installed: [ + makePluginSummary(), + makePluginSummary({ + id: 'another-plugin', + displayName: 'Another Plugin', + version: '1.0.0', + }), + ], + }); + const notifier = makeNotifier(harness); + + await Promise.all([ + notifier.handlePluginCommandCompleted('kimi-datasource'), + notifier.handlePluginCommandCompleted('another-plugin'), + ]); + + expect(harness.notify).toHaveBeenCalledTimes(2); + // Both entries must survive in the persisted state — no lost update. + const state = await readPluginUpdateNoticeState(stateFile); + expect(state.notified).toEqual({ + 'kimi-datasource': '3.4.0', + 'another-plugin': '2.0.0', + }); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts new file mode 100644 index 0000000000..3220d1b916 --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { PluginUpdateNotifier } from '#/tui/controllers/plugin-update-notifier'; +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +const DATASOURCE_TOOL = 'mcp__plugin-kimi-datasource_data__call_data_source_tool'; + +function makeHost() { + const streamingUI = { + setTurnId: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + setStep: vi.fn(), + finalizeTurn: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), + registerToolCall: vi.fn(), + completeToolResult: vi.fn(), + setTodoList: vi.fn(), + }; + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'waiting', + model: 'kimi-model', + permissionMode: 'auto', + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: {}, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI, + requireSession: vi.fn(() => ({})), + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + updateActivityPane: vi.fn(), + track: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as never, streamingUI }; +} + +function makeNotifier() { + return { + handleMcpToolCompleted: vi.fn(), + handlePluginCommandCompleted: vi.fn(), + }; +} + +function toolCallStarted(name: string) { + return { + type: 'tool.call.started', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + name, + args: {}, + } as never; +} + +function toolResult() { + return { + type: 'tool.result', + sessionId: 's1', + agentId: 'main', + turnId: 1, + toolCallId: 't1', + output: 'ok', + } as never; +} + +function turnEnded(reason: string, turnId = 1) { + return { + type: 'turn.ended', + sessionId: 's1', + agentId: 'main', + turnId, + reason, + } as never; +} + +function pluginCommandTurnStarted() { + return { + type: 'turn.started', + sessionId: 's1', + agentId: 'main', + turnId: 2, + origin: { + kind: 'plugin_command', + activationId: 'a1', + pluginId: 'kimi-datasource', + commandName: 'setup', + trigger: 'user-slash', + }, + } as never; +} + +const sendQueued = (): void => {}; + +describe('SessionEventHandler plugin update notices', () => { + it('reports plugin MCP usage only when the turn ends', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + // The tool result alone must not trigger the notice mid-turn. + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handleMcpToolCompleted).toHaveBeenCalledWith(DATASOURCE_TOOL); + }); + + it('skips the notice for a cancelled turn and clears the buffer', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: DATASOURCE_TOOL, args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted(DATASOURCE_TOOL), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('cancelled'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + + // A later completed turn must not replay the cancelled turn's usage. + handler.handleEvent(turnEnded('completed', 3), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('ignores non-plugin tools', () => { + const { host, streamingUI } = makeHost(); + const notifier = makeNotifier(); + streamingUI.completeToolResult.mockReturnValue({ name: 'Bash', args: {} }); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(toolCallStarted('Bash'), sendQueued); + handler.handleEvent(toolResult(), sendQueued); + handler.handleEvent(turnEnded('completed'), sendQueued); + expect(notifier.handleMcpToolCompleted).not.toHaveBeenCalled(); + }); + + it('reports a finished plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('completed', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledTimes(1); + expect(notifier.handlePluginCommandCompleted).toHaveBeenCalledWith('kimi-datasource'); + }); + + it('skips a cancelled plugin command turn', () => { + const { host } = makeHost(); + const notifier = makeNotifier(); + const handler = new SessionEventHandler(host, notifier as unknown as PluginUpdateNotifier); + + handler.handleEvent(pluginCommandTurnStarted(), sendQueued); + handler.handleEvent(turnEnded('cancelled', 2), sendQueued); + expect(notifier.handlePluginCommandCompleted).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index 7918d998f5..13ee90ceba 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -4194,6 +4194,75 @@ command = "vim" }); }); + it('shows a quota note after installing a quota-consuming official plugin', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'zip-url', + originalSource: 'https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + })), + }); + const { driver } = await makeDriver(session); + + // Official sources skip the trust prompt, so the install runs immediately. + driver.handleUserInput( + '/plugins install https://code.kimi.com/kimi-code/plugins/official/kimi-datasource.zip', + ); + + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).toContain('Note: This plugin consumes your quota.'); + }); + }); + + it('does not show the quota note for a same-id fork installed from a local path', async () => { + const session = makeSession({ + installPlugin: vi.fn(async () => ({ + id: 'kimi-datasource', + displayName: 'Kimi Datasource', + version: '3.3.0', + enabled: true, + state: 'ok', + skillCount: 0, + mcpServerCount: 1, + enabledMcpServerCount: 1, + hasErrors: false, + source: 'local-path', + })), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('/plugins install ./plugins/kimi-datasource-fork'); + + await vi.waitFor(() => { + expect(driver.state.editorContainer.children[0]).toBeInstanceOf( + PluginInstallTrustConfirmComponent, + ); + }); + const confirm = driver.state.editorContainer.children[0] as PluginInstallTrustConfirmComponent; + confirm.handleInput('\u001B[B'); // switch from "Exit" to "Trust and install" + confirm.handleInput('\r'); + + // The manifest id matches a billed plugin, but a local-path install is + // not the official quota-consuming build. + await vi.waitFor(() => { + const transcript = stripSgr(renderTranscript(driver)); + expect(transcript).toContain('Installed Kimi Datasource'); + }); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Note: This plugin consumes your quota.', + ); + }); + it('does not install when the third-party trust prompt is dismissed', async () => { const session = makeSession(); const { driver } = await makeDriver(session); @@ -4260,6 +4329,7 @@ command = "vim" const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('Installed Demo'); expect(transcript).toContain('Run /new or /reload to apply plugin changes.'); + expect(transcript).not.toContain('Note: This plugin consumes your quota.'); }); // Installing closes the panel so the success notice / reload tip is visible. await vi.waitFor(() => { diff --git a/docs/en/customization/plugins.md b/docs/en/customization/plugins.md index 52b86e7e43..486e0d9205 100644 --- a/docs/en/customization/plugins.md +++ b/docs/en/customization/plugins.md @@ -33,7 +33,7 @@ You can also use slash commands directly: | `/plugins mcp enable ` | Enable an MCP server declared by a plugin | | `/plugins mcp disable ` | Disable an MCP server declared by a plugin | -The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. +The **Installed** tab lists your installed plugins and shows an update badge when a newer version is available in the marketplace. When a turn that used an outdated plugin (its MCP tool or a `/:` slash command) ends, a one-time notice also points you to `/plugins` for the update; each new marketplace version is announced once. The **Official** and **Third-party** tabs list marketplace plugins by tier; the **Custom** tab installs from a URL. Marketplace catalogs load automatically when needed. Each install shows a trust badge: `kimi-official` (from an official address), `curated` (from a curated address), or `third-party` (everything else). Installing a third-party plugin (anything not from the official address, including Custom installs) first shows a confirmation prompt that defaults to cancelling, so it is only installed if you choose to trust the source. ### Installing from GitHub @@ -82,7 +82,7 @@ You must first complete OAuth login with a Kimi Code account via `/login`. The p 2. Find **Kimi Datasource** and press `Enter` to install 3. After installation completes, run `/reload` or `/new` to activate the plugin -The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. +Using Kimi Datasource consumes your Kimi Code plan quota; the install result reminds you of this. The current latest version is v3.3.0. The plugin does not update automatically — to upgrade to a newer version, repeat the installation steps above. ### How to use diff --git a/docs/zh/customization/plugins.md b/docs/zh/customization/plugins.md index f3fe7499c8..cd07290fa3 100644 --- a/docs/zh/customization/plugins.md +++ b/docs/zh/customization/plugins.md @@ -33,7 +33,7 @@ Plugins 把可复用的 Kimi Code CLI 能力打包成可安装单元——可以 | `/plugins mcp enable ` | 启用 plugin 声明的 MCP server | | `/plugins mcp disable ` | 禁用 plugin 声明的 MCP server | -**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 +**Installed** tab 列出已安装的 plugin,并在 marketplace 有更新版本时显示更新徽章。当一个使用了过时 plugin(其 MCP 工具或 `/:` 斜杠命令)的 turn 结束后,也会出现一次性提示,引导你到 `/plugins` 更新;每个新的 marketplace 版本只提醒一次。**Official** 和 **Third-party** tab 按 tier 列出 marketplace plugin;**Custom** tab 从 URL 安装。marketplace 目录会在需要时自动加载。每个安装会显示信任徽章:`kimi-official`(来自官方地址)、`curated`(来自精选地址)、`third-party`(其他所有情况)。安装第三方 plugin(任何非官方地址的 plugin,包括 Custom 安装)会先显示一个默认「取消」的确认提示,只有在你选择信任该来源后才会继续安装。 ### 从 GitHub 安装 @@ -82,7 +82,7 @@ Kimi Datasource 是 Kimi Code 官方数据插件,让你通过自然语言直 2. 找到 **Kimi Datasource**,按 `Enter` 安装 3. 安装完成后运行 `/reload` 或 `/new` 激活 plugin -当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 +使用 Kimi Datasource 会消耗你的 Kimi Code 套餐额度,安装结果中会提示这一点。当前最新版本为 v3.3.0。插件安装后不会自动更新,如需升级到新版本,重新执行上述安装步骤即可。 ### 使用方式 From 77618e38c35a81e26134b3f83eb7f2b460c0ee05 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Mon, 27 Jul 2026 17:18:16 +0800 Subject: [PATCH 11/11] feat(kap-server): wire cloud telemetry for engine events (#2230) * feat(kap-server): wire cloud telemetry for engine events The v2 engine registers a full telemetry event catalog, but kap-server never attached an appender, so events from web-hosted sessions were dropped to the null appender. Add an opt-in `telemetry` start option that attaches a CloudAppender (app_name kimi-code-cli, ui_mode web, matching the v1 `kimi web` host conventions), still gated by the config `telemetry` toggle, with periodic flush and a bounded flush on close. The option defaults off so tests never post to the real endpoint; the CLI's `kimi web` host enables it. * fix(kap-server): honor telemetry disable environment * fix(agent-core-v2): isolate session telemetry context * fix(kap-server): seed telemetry client version * fix(cli): share telemetry shutdown deadline * fix(telemetry): make shutdown ownership durable * fix(kap-server): keep telemetry shutdown best-effort --- apps/kimi-code/src/cli/sub/web/run.ts | 4 + .../sessionLifecycleService.ts | 26 ++-- .../src/app/telemetry/cloudAppender.ts | 3 +- .../sessionLifecycle/sessionLifecycle.test.ts | 26 ++++ .../test/app/telemetry/cloudAppender.test.ts | 19 +++ packages/kap-server/package.json | 1 + packages/kap-server/src/services/telemetry.ts | 93 ++++++++++++ packages/kap-server/src/start.ts | 48 +++++- packages/kap-server/test/boot.test.ts | 80 +++++++++- packages/kap-server/test/telemetry.test.ts | 139 ++++++++++++++++++ pnpm-lock.yaml | 3 + 11 files changed, 423 insertions(+), 19 deletions(-) create mode 100644 packages/kap-server/src/services/telemetry.ts create mode 100644 packages/kap-server/test/telemetry.test.ts diff --git a/apps/kimi-code/src/cli/sub/web/run.ts b/apps/kimi-code/src/cli/sub/web/run.ts index 1a4fcbaa84..540ef778cf 100644 --- a/apps/kimi-code/src/cli/sub/web/run.ts +++ b/apps/kimi-code/src/cli/sub/web/run.ts @@ -280,6 +280,10 @@ async function runServerInProcess( allowRemoteTerminals: options.allowRemoteTerminals, allowedHosts: options.allowedHosts, disableAuth: options.dangerousBypassAuth, + // Attach the engine's cloud telemetry appender (still gated by the config + // `telemetry` toggle). Complements the v1 client registered above, which + // only covers host-level events. + telemetry: true, // Seed the CLI's Kimi identity headers so the engine's outbound // requests (model, WebSearch, FetchURL) carry the same User-Agent + // X-Msh-* identity as direct CLI runs. diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index fd871c7272..accd49cfd9 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -7,10 +7,9 @@ * close/archive — archiving flags the session's `sessionMetadata`, removes * its `agentLifecycle` agents, restoring clears the archived flag, and * broadcasts through `event`; session start and resume failures are reported - * through `telemetry`. Because hosts bind their telemetry context only after - * create()/resume() returns, the created-session announcement binds the - * session id into telemetry context before emitting `session_started`, and the - * resume-failure path does the same before `session_load_failed`. + * through `telemetry`. Each Session scope receives a telemetry view bound to + * its session id, while failures before a scope is available use an ephemeral + * context view. * Materializes the session's initial metadata on * creation by resolving `sessionMetadata`. Bound at App scope. Persisted * sessions are discovered through the `sessionIndex` read model, and workspace @@ -206,7 +205,10 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec LifecycleScope.Session, opts.sessionId, { - extra: [...sessionContextSeed(ctx)], + extra: [ + ...sessionContextSeed(ctx), + [ITelemetryService, this.telemetry.withContext({ sessionId: opts.sessionId })], + ], }, ) as ISessionScopeHandle; if (additionalDirs.length > 0) { @@ -250,8 +252,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec private async announceCreated(event: SessionCreatedEvent): Promise { await this.hooks.onDidCreateSession.run(event); this._onDidCreateSession.fire(event); - this.telemetry.setContext({ sessionId: event.sessionId }); - this.telemetry.track2('session_started', { resumed: event.source === 'resume' }); + event.handle.accessor + .get(ITelemetryService) + .track2('session_started', { resumed: event.source === 'resume' }); } get(sessionId: string): ISessionScopeHandle | undefined { @@ -266,10 +269,11 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec if (live !== undefined) return Promise.resolve(live); const promise = this.doResume(sessionId) .catch((error: unknown) => { - this.telemetry.setContext({ sessionId }); - this.telemetry.track2('session_load_failed', { - reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', - }); + this.telemetry + .withContext({ sessionId }) + .track2('session_load_failed', { + reason: isError2(error) ? error.code : error instanceof Error ? error.name : 'unknown', + }); throw error; }) .finally(() => this.resuming.delete(sessionId)); diff --git a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts index 4687fca3e2..fd0cb5655f 100644 --- a/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts +++ b/packages/agent-core-v2/src/app/telemetry/cloudAppender.ts @@ -105,10 +105,11 @@ export class CloudAppender implements ITelemetryAppender { } track(event: string, properties?: TelemetryProperties): void { + const eventSessionId = properties?.['sessionId']; const enriched: EnrichedCloudEvent = { event_id: randomUUID().replaceAll('-', ''), device_id: this.deviceId, - session_id: this.sessionId, + session_id: typeof eventSessionId === 'string' ? eventSessionId : this.sessionId, event, timestamp: Date.now() / 1000, properties: cleanTelemetryProperties(sanitizeProperties(properties)), diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index adcf475b52..40766015e6 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -848,6 +848,32 @@ describe('SessionLifecycleService', () => { }); }); + it('keeps telemetry session context isolated when multiple sessions emit interleaved events', async () => { + const svc = build(); + const first = await svc.create({ sessionId: 'first', workDir: '/tmp/proj' }); + const second = await svc.create({ sessionId: 'second', workDir: '/tmp/proj' }); + telemetryRecords.length = 0; + + first.accessor.get(ITelemetryService).track('test_event', { marker: 'first-before' }); + second.accessor.get(ITelemetryService).track('test_event', { marker: 'second' }); + first.accessor.get(ITelemetryService).track('test_event', { marker: 'first-after' }); + + expect(telemetryRecords).toEqual([ + { + event: 'test_event', + properties: { sessionId: 'first', marker: 'first-before' }, + }, + { + event: 'test_event', + properties: { sessionId: 'second', marker: 'second' }, + }, + { + event: 'test_event', + properties: { sessionId: 'first', marker: 'first-after' }, + }, + ]); + }); + it('emits session_started with resumed: true and the bound session id on resume', async () => { const workDir = '/tmp/proj'; const svc = build([ diff --git a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts index 5ade070259..3c3abf3b3b 100644 --- a/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts +++ b/packages/agent-core-v2/test/app/telemetry/cloudAppender.test.ts @@ -126,6 +126,25 @@ describe('CloudAppender', () => { expect(event?.['context_model']).toBe('switched-model'); }); + it('uses the event sessionId for top-level session_id when it differs from appender context', async () => { + const requests: CapturedRequest[] = []; + const appender = new CloudAppender( + baseOptions({ + homeDir, + sessionId: 'default-session', + fetchImpl: makeFetch((req) => { + requests.push(req); + return okResponse(); + }), + }), + ); + + appender.track('evt', { sessionId: 'event-session' }); + await appender.flush(); + + expect(requests[0]?.body.events[0]?.['session_id']).toBe('event-session'); + }); + it('sends Authorization header when a token is provided', async () => { const requests: CapturedRequest[] = []; const appender = new CloudAppender( diff --git a/packages/kap-server/package.json b/packages/kap-server/package.json index ae8bd7a6cf..e3b4e8e062 100644 --- a/packages/kap-server/package.json +++ b/packages/kap-server/package.json @@ -24,6 +24,7 @@ "@fastify/multipart": "^10.0.0", "@fastify/swagger": "^9.7.0", "@moonshot-ai/agent-core-v2": "workspace:^", + "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/transcript": "workspace:^", "bcryptjs": "^2.4.3", "fastify": "^5.1.0", diff --git a/packages/kap-server/src/services/telemetry.ts b/packages/kap-server/src/services/telemetry.ts new file mode 100644 index 0000000000..25efe53505 --- /dev/null +++ b/packages/kap-server/src/services/telemetry.ts @@ -0,0 +1,93 @@ +/** + * Server telemetry bootstrap — wires agent-core-v2's `CloudAppender` into the + * App-scoped `ITelemetryService` so engine events emitted inside the server + * process (`session_started`, turn / tool / permission events, `image_compress`, + * …) actually leave the process. Mirrors the v1 `kimi web` host + * (`initializeServerTelemetry` in apps/kimi-code): same product app name, the + * surface distinguished by `ui_mode = "web"`, the config `telemetry` toggle + * honored at startup (a change takes effect on restart). + */ + +import { + type CloudAppender, + createCloudAppender, + IBootstrapService, + IConfigService, + type IDisposable, + IOAuthToolkit, + ITelemetryService, + type Scope, +} from '@moonshot-ai/agent-core-v2'; +import { createKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; + +// Same product as the CLI; the surface is distinguished by ui_mode (v1 +// convention: CLI_USER_AGENT_PRODUCT / WEB_UI_MODE in apps/kimi-code). +const SERVER_TELEMETRY_APP_NAME = 'kimi-code-cli'; +const SERVER_TELEMETRY_UI_MODE = 'web'; +const TELEMETRY_DISABLE_ENV = 'KIMI_DISABLE_TELEMETRY'; +const TELEMETRY_DISABLE_ENV_VALUES = new Set(['1', 'true', 't', 'yes', 'y']); + +/** + * Cap on how long server close waits for its best-effort final flush. A wedged + * telemetry endpoint must not hold shutdown hostage. + */ +const TELEMETRY_SHUTDOWN_TIMEOUT_MS = 3_000; + +export interface ServerTelemetry { + /** Present only when telemetry is enabled by both config and environment. */ + readonly appender?: CloudAppender; + readonly registration?: IDisposable; +} + +function isTelemetryDisabledByEnv(core: Scope): boolean { + const value = core.accessor.get(IBootstrapService).getEnv(TELEMETRY_DISABLE_ENV); + return value !== undefined && TELEMETRY_DISABLE_ENV_VALUES.has(value.trim().toLowerCase()); +} + +export async function initializeServerTelemetry( + core: Scope, + homeDir: string, +): Promise { + const service = core.accessor.get(ITelemetryService); + const config = core.accessor.get(IConfigService); + await config.ready; + const enabled = config.get('telemetry') !== false; + if (!enabled || isTelemetryDisabledByEnv(core)) return {}; + + const auth = core.accessor.get(IOAuthToolkit); + const appender = createCloudAppender(core.accessor, { + deviceId: createKimiDeviceId(homeDir), + appName: SERVER_TELEMETRY_APP_NAME, + uiMode: SERVER_TELEMETRY_UI_MODE, + model: config.get('defaultModel') ?? undefined, + getAccessToken: async () => (await auth.getCachedAccessToken()) ?? null, + }); + const registration = service.addAppender(appender); + try { + // The server is long-lived: flush on a timer, not only at the threshold. + appender.startPeriodicFlush(); + } catch (error) { + registration.dispose(); + throw error; + } + return { appender, registration }; +} + +export async function shutdownServerTelemetry( + telemetry: ServerTelemetry, + deadlineMs = Date.now() + TELEMETRY_SHUTDOWN_TIMEOUT_MS, +): Promise { + telemetry.registration?.dispose(); + if (telemetry.appender === undefined) return; + let timer: ReturnType | undefined; + try { + await Promise.race([ + telemetry.appender.shutdown(), + new Promise((resolve) => { + timer = setTimeout(resolve, Math.max(0, deadlineMs - Date.now())); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } +} diff --git a/packages/kap-server/src/start.ts b/packages/kap-server/src/start.ts index 6fe6b2626d..47c0ca30ab 100644 --- a/packages/kap-server/src/start.ts +++ b/packages/kap-server/src/start.ts @@ -63,6 +63,11 @@ import { createSecurityHeadersHook } from './middleware/securityHeaders'; import { createAuthHook } from './middleware/auth'; import { GuiStoreService } from './services/guiStore/guiStoreService'; import { loadSnapshotConfig, SnapshotReader } from './services/snapshot'; +import { + initializeServerTelemetry, + type ServerTelemetry, + shutdownServerTelemetry, +} from './services/telemetry'; import { TranscriptService } from './services/transcript/transcriptService'; import { ModelCatalogRefreshScheduler } from './services/modelCatalog/modelCatalogRefreshScheduler'; import { createAuthFailureLimiter } from './middleware/rateLimit'; @@ -134,6 +139,14 @@ export interface ServerStartOptions { * embedding hosts (the CLI) should pass their own version. */ readonly version?: string; + /** + * Opt-in cloud telemetry for the engine's `ITelemetryService` events: when + * true, a `CloudAppender` is attached at startup (still gated by the config + * `telemetry` toggle) and flushed on close. Defaults to false so tests and + * embedding hosts that wire their own telemetry never post to the real + * endpoint unintentionally; the CLI's `kimi web` host passes true. + */ + readonly telemetry?: boolean; } export interface RunningServer { @@ -212,7 +225,7 @@ export async function startServer(opts: ServerStartOptions = {}): Promise { // ... and it backs the default product User-Agent. const defaults = server.core.accessor.get(IHostRequestHeaders); expect(defaults.headers['User-Agent']).toBe('kimi-code-cli/9.9.9-host'); + expect(server.core.accessor.get(IBootstrapService).clientVersion).toBe('9.9.9-host'); }); it('seeds a default product User-Agent that opts.seeds can override', async () => { @@ -158,6 +175,61 @@ describe('server-v2 boot', () => { }); expect(server.core.accessor.get(ISkillCatalogRuntimeOptions).explicitDirs).toBeUndefined(); }); + + it('does not shut down a host-injected telemetry service when server telemetry is disabled', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-host-telemetry-')); + await writeFile(join(home, 'config.toml'), 'telemetry = false\n', 'utf8'); + const shutdown = vi.fn(async () => {}); + + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + seeds: [[ITelemetryService, { ...noopTelemetryService, shutdown }]], + }); + + await server.close(); + server = undefined; + + expect(shutdown).not.toHaveBeenCalled(); + }); + + it('completes server cleanup when owned telemetry shutdown fails', async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-telemetry-failure-')); + const storage = new InMemoryStorageService(); + const write = storage.write.bind(storage); + vi.spyOn(storage, 'write').mockImplementation(async (scope, key, data, options) => { + if (scope === 'telemetry') throw new Error('telemetry storage unavailable'); + await write(scope, key, data, options); + }); + const auth = { + _serviceBrand: undefined, + getCachedAccessToken: async () => { + throw new Error('telemetry auth unavailable'); + }, + } as unknown as IOAuthToolkit; + + server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir: home, + logLevel: 'silent', + telemetry: true, + seeds: [ + [IFileSystemStorageService, storage], + [IOAuthToolkit, auth], + ], + }); + const core = server.core; + core.accessor.get(ITelemetryService).track('server_probe'); + + await server.close(); + server = undefined; + + expect(() => core.accessor.get(IBootstrapService)).toThrow(); + expect(await listLiveServerInstances(home)).toEqual([]); + }); }); function silentLogger() { diff --git a/packages/kap-server/test/telemetry.test.ts b/packages/kap-server/test/telemetry.test.ts new file mode 100644 index 0000000000..5892691dfd --- /dev/null +++ b/packages/kap-server/test/telemetry.test.ts @@ -0,0 +1,139 @@ +/** + * Kap server telemetry composition tests — boot the real App scope and storage, + * then verify config gating, host-appender preservation, cloud delivery wiring, + * and owned shutdown. The outbound cloud fetch is stubbed at the network + * boundary. + */ + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + bootstrap, + type ITelemetryAppender, + ITelemetryService, + IOAuthToolkit, + logSeed, + resolveConfigPath, + resolveLoggingConfig, + type Scope, + type ScopeSeed, + TelemetryService, +} from '@moonshot-ai/agent-core-v2'; +import { readKimiDeviceId } from '@moonshot-ai/kimi-code-oauth'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { initializeServerTelemetry, shutdownServerTelemetry } from '../src/services/telemetry'; + +describe('server telemetry', () => { + let home: string | undefined; + let core: Scope | undefined; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-server-telemetry-')); + }); + + afterEach(async () => { + vi.unstubAllGlobals(); + core?.dispose(); + core = undefined; + if (home !== undefined) { + await rm(home, { recursive: true, force: true }); + home = undefined; + } + }); + + async function bootCore( + toml?: string, + env?: NodeJS.ProcessEnv, + seeds: ScopeSeed = [], + ): Promise { + const resolvedEnv = env ?? { + ...process.env, + KIMI_DISABLE_TELEMETRY: undefined, + }; + if (toml !== undefined) { + await writeFile(join(home as string, 'config.toml'), toml, 'utf-8'); + } + const { app } = bootstrap( + { + homeDir: home as string, + configPath: resolveConfigPath({ homeDir: home as string }), + env: resolvedEnv, + }, + [ + ...logSeed(resolveLoggingConfig({ homeDir: home as string, env: resolvedEnv })), + ...seeds, + ], + ); + core = app; + return app; + } + + it('attaches the cloud appender by default and persists the device id', async () => { + const app = await bootCore(); + const telemetry = await initializeServerTelemetry(app, home as string); + expect(telemetry.appender).toBeDefined(); + expect(readKimiDeviceId(home as string)).not.toBeNull(); + await shutdownServerTelemetry(telemetry); + }); + + it('keeps host delivery independent of the server-owned cloud appender lifecycle', async () => { + const cloudFetch = vi.fn(async () => new Response(null, { status: 204 })); + vi.stubGlobal('fetch', cloudFetch); + const hostEvents: string[] = []; + const hostAppender: ITelemetryAppender = { + track: (event) => hostEvents.push(event), + }; + const hostTelemetry = new TelemetryService(); + hostTelemetry.addAppender(hostAppender); + const app = await bootCore(undefined, undefined, [[ITelemetryService, hostTelemetry]]); + const telemetry = await initializeServerTelemetry(app, home as string); + const service = app.accessor.get(ITelemetryService); + + service.track('server_probe'); + + expect(hostEvents).toEqual(['server_probe']); + + await shutdownServerTelemetry(telemetry); + service.track('host_after_server_shutdown'); + await service.flush(); + + expect(cloudFetch).toHaveBeenCalledOnce(); + expect(hostEvents).toEqual(['server_probe', 'host_after_server_shutdown']); + }); + + it('returns at the deadline when cloud delivery never settles', async () => { + const auth = { + _serviceBrand: undefined, + getCachedAccessToken: () => new Promise(() => {}), + } as unknown as IOAuthToolkit; + const app = await bootCore(undefined, undefined, [[IOAuthToolkit, auth]]); + const telemetry = await initializeServerTelemetry(app, home as string); + app.accessor.get(ITelemetryService).track('server_probe'); + + await expect(shutdownServerTelemetry(telemetry, Date.now())).resolves.toBeUndefined(); + }); + + it('keeps the null appender when config sets telemetry = false', async () => { + const app = await bootCore('telemetry = false\n'); + const telemetry = await initializeServerTelemetry(app, home as string); + expect(telemetry.appender).toBeUndefined(); + await shutdownServerTelemetry(telemetry); + }); + + it.each(['1', 'true', 't', 'yes', 'y', ' TRUE '])( + 'keeps the null appender when KIMI_DISABLE_TELEMETRY=%s', + async (value) => { + const app = await bootCore(undefined, { + ...process.env, + KIMI_DISABLE_TELEMETRY: value, + }); + const telemetry = await initializeServerTelemetry(app, home as string); + expect(telemetry.appender).toBeUndefined(); + expect(readKimiDeviceId(home as string)).toBeNull(); + await shutdownServerTelemetry(telemetry); + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 251932976a..ecf1623ff1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -835,6 +835,9 @@ importers: '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../agent-core-v2 + '@moonshot-ai/kimi-code-oauth': + specifier: workspace:^ + version: link:../oauth '@moonshot-ai/transcript': specifier: workspace:^ version: link:../transcript