From c3cf05de702d1b36ca56d5975695cd38e4fc9036 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 18:19:45 +0800 Subject: [PATCH 1/7] 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 --- .agents/skills/agent-core-dev/orient.md | 2 +- .changeset/undo-rewind-consistency.md | 5 + apps/kimi-code/src/tui/commands/undo.ts | 19 +- docs/en/reference/slash-commands.md | 2 +- docs/zh/reference/slash-commands.md | 2 +- .../scripts/check-domain-layers.mjs | 5 + .../contextInjector/contextInjectorService.ts | 8 + .../src/agent/contextMemory/contextMemory.ts | 3 - .../contextMemory/contextMemoryService.ts | 43 +--- .../src/agent/contextMemory/contextOps.ts | 33 +-- .../agent/contextMemory/contextTranscript.ts | 57 +++++- .../agent/fullCompaction/fullCompaction.ts | 6 + .../fullCompaction/fullCompactionService.ts | 14 ++ .../src/agent/loop/turnIndexOps.ts | 42 ++++ .../agent-core-v2/src/agent/plan/planOps.ts | 4 +- .../agent-core-v2/src/agent/prompt/prompt.ts | 10 +- .../src/agent/prompt/promptService.ts | 30 ++- .../agent-core-v2/src/agent/rewind/rewind.ts | 41 ++++ .../src/agent/rewind/rewindService.ts | 188 ++++++++++++++++++ .../agent-core-v2/src/agent/rpc/core-api.ts | 2 +- .../agent-core-v2/src/agent/rpc/rpcService.ts | 10 +- .../src/agent/task/taskService.ts | 13 ++ .../src/agent/toolSelect/toolSelectService.ts | 20 +- packages/agent-core-v2/src/index.ts | 2 + .../agent-core-v2/src/session/todo/todoOps.ts | 5 +- packages/agent-core-v2/src/wire/errors.ts | 7 + packages/agent-core-v2/src/wire/model.ts | 21 +- packages/agent-core-v2/src/wire/op.ts | 24 ++- packages/agent-core-v2/src/wire/record.ts | 36 ++++ packages/agent-core-v2/src/wire/types.ts | 6 +- packages/agent-core-v2/src/wire/wire.ts | 7 + .../agent-core-v2/src/wire/wireService.ts | 149 +++++++++++++- .../test/agent/contextMemory/context.test.ts | 32 +-- .../contextMemory/contextTranscript.test.ts | 41 ++++ .../test/agent/contextMemory/stubs.ts | 13 -- .../agent/contextMemory/undoPrecheck.test.ts | 106 ++++++---- .../test/agent/plan/plan.test.ts | 4 +- .../test/agent/rewind/rewind.test.ts | 119 +++++++++++ .../test/agent/rpc/undoHistory.test.ts | 2 +- .../test/agent/skill/skill.test.ts | 2 - .../test/agent/task/taskService.test.ts | 1 + .../agent/toolSelect/toolSelect.e2e.test.ts | 3 +- .../externalHooksRunner/integration.test.ts | 8 - .../test/app/gateway/gateway.test.ts | 2 +- packages/agent-core-v2/test/harness/agent.ts | 49 ++++- .../agent-core-v2/test/wire/rewind.test.ts | 137 +++++++++++++ packages/agent-core-v2/test/wire/stubs.ts | 1 + packages/kap-server/src/routes/sessions.ts | 15 +- .../src/services/transcript/coreEventMap.ts | 5 + .../ws/v1/sessionEventBroadcaster.ts | 5 + packages/kap-server/test/sessions.test.ts | 4 +- 51 files changed, 1154 insertions(+), 211 deletions(-) create mode 100644 .changeset/undo-rewind-consistency.md create mode 100644 packages/agent-core-v2/src/agent/loop/turnIndexOps.ts create mode 100644 packages/agent-core-v2/src/agent/rewind/rewind.ts create mode 100644 packages/agent-core-v2/src/agent/rewind/rewindService.ts create mode 100644 packages/agent-core-v2/test/agent/rewind/rewind.test.ts create mode 100644 packages/agent-core-v2/test/wire/rewind.test.ts diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 3047c43d45..05d1b0ed7e 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`, `rewind` | | 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..8f0a5cfc0f --- /dev/null +++ b/.changeset/undo-rewind-consistency.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Make /undo consistent and safe: undoing now also rolls back the todo list, plan mode, and background-task notifications from the undone turns, no longer corrupts the conversation when used while a turn or compaction is running, and restores the friendly limit message when there is nothing to undo. diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 5db5871744..862a5b294b 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -375,18 +375,17 @@ function undoLimitFromError( ): (UndoAvailability & { readonly requestedCount: number }) | undefined { if (!isKimiError(error)) return undefined; const details = error.details; - if (details?.['reason'] !== 'undo_limit') return undefined; - const requestedCount = details['requestedCount']; - const maxCount = details['undoableCount']; - const stoppedAtCompaction = details['stoppedAtCompaction']; - if ( - typeof requestedCount !== 'number' || - typeof maxCount !== 'number' || - typeof stoppedAtCompaction !== 'boolean' - ) { + // server-v2 rewind error shape: { reason, requestedCount, undoableCount } + const reason = details?.['reason']; + if (reason !== 'empty' && reason !== 'compaction_boundary' && reason !== 'insufficient') { + return undefined; + } + const requestedCount = details?.['requestedCount']; + const maxCount = details?.['undoableCount']; + if (typeof requestedCount !== 'number' || typeof maxCount !== 'number') { return undefined; } - return { requestedCount, maxCount, stoppedAtCompaction }; + return { requestedCount, maxCount, stoppedAtCompaction: reason === 'compaction_boundary' }; } function isUndoAnchorEntry(entry: TranscriptEntry): boolean { 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/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index d02848869c..05c4d5ffdd 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -214,6 +214,11 @@ const DOMAIN_LAYER = new Map([ ['sessionExport', 6], ['interaction', 6], ['sessionMetadata', 6], + // `rewind` owns the undo pipeline (quiesce → log.cut → reconcile): it + // coordinates L4 agent domains (loop / prompt / contextMemory / + // fullCompaction) plus `sessionMetadata` for the `lastPrompt` reconcile, + // so it sits in L6 beside the other cross-agent coordinators. + ['rewind', 6], ['sessionActivity', 6], ['session', 6], ['terminal', 6], diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 0c0934a166..037595198e 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -56,6 +56,14 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.handleSplice(e); }), ); + this._register( + this.eventBus.subscribe('context.rewound', () => { + // A rewind rebuilds the context wholesale; live positions are + // re-derived from the surviving history, so reminders cut by the + // rewind are re-announced on the next inject (same as post-restore). + this.resyncPositions(); + }), + ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { this.resyncPositions(); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index 3334d76ab4..f9e8ea77d7 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -1,6 +1,5 @@ import { createDecorator } from "#/_base/di/instantiation"; -import type { UndoCut } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -37,8 +36,6 @@ export interface IAgentContextMemoryService { clear(): void; - undo(count: number): UndoCut; - applyCompaction(input: ContextCompactionInput): ContextCompactionResult; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 53485e99e4..f66f9dea86 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -3,13 +3,13 @@ * * Owns the per-agent conversation history in the wire `ContextModel` * (`ContextMessage[]`): reads through `wire.getModel`, writes through the - * v1 wire Ops (`append` / `appendLoopEvent` / `clear` / `undo` / - * `applyCompaction`). + * v1 wire Ops (`append` / `appendLoopEvent` / `clear` / `applyCompaction`). + * (Undo no longer flows through here: the `rewind` domain cuts the journal + * with a `log.cut` control record and the wire rebuilds this Model.) * As the sole live mutation gateway for the history, it also cascades a * (non-persisted) `context_size.measured` Op alongside every mutation that - * changes the measured prefix — `clear` resets it, `applyCompaction` adopts - * `tokensAfter`, and `undo` rebases it (to an estimate when the measured - * aggregate is truncated); `append` leaves the measured prefix untouched since + * changes the measured prefix — `clear` resets it and `applyCompaction` + * adopts `tokensAfter`; `append` leaves the measured prefix untouched since * new messages are the unmeasured tail (see `contextSizeService`). * Splice-shaped mutations publish `context.spliced` from the live path only * (replay rebuilds the Model silently and never invokes these methods), so @@ -24,11 +24,9 @@ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { estimateTokensForMessages } from '#/kosong/contract/tokens'; import { IEventBus } from '#/app/event/eventBus'; -import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; +import { contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; import { IWireService } from '#/wire/wire'; -import type { Op } from '#/wire/op'; import { IAgentContextMemoryService, @@ -37,15 +35,11 @@ import { } from './contextMemory'; import { buildContextCompactionShape } from './compactionHandoff'; import { - computeUndoCut, ContextModel, contextAppendLoopEvent, contextAppendMessage, contextApplyCompaction, contextClear, - contextUndo, - isFullyUndoable, - type UndoCut, } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -93,20 +87,6 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.publishSplice({ start: 0, deleteCount, messages: [] }); } - undo(count: number): UndoCut { - const history = this.get(); - const cut = computeUndoCut(history, count); - if (isFullyUndoable(cut, count)) { - this.wire.dispatch(contextUndo({ count }), ...this.sizeOpsForCut(cut.cutIndex, history)); - this.publishSplice({ - start: cut.cutIndex, - deleteCount: history.length - cut.cutIndex, - messages: [], - }); - } - return cut; - } - applyCompaction(input: ContextCompactionInput): ContextCompactionResult { const history = this.get(); const result = buildContextCompactionShape(history, input); @@ -142,17 +122,6 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte }): void { this.eventBus.publish({ type: 'context.spliced', ...input }); } - - private sizeOpsForCut(cutIndex: number, history: readonly ContextMessage[]): Op[] { - const model = this.wire.getModel(ContextSizeModel); - if (model.length <= cutIndex) return []; - return [ - contextSizeMeasured({ - length: cutIndex, - tokens: estimateTokensForMessages(history.slice(0, cutIndex)), - }), - ]; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 8446efadd0..1aefe5fc93 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -101,6 +101,7 @@ async function dehydrateRecord( } export const ContextModel = defineModel('contextMemory', () => [], { + rewindable: true, blobs: { dehydrate: dehydrateRecord, rehydrate: async (state, transform) => { @@ -319,36 +320,18 @@ export function isFullyUndoable(cut: UndoCut, count: number): boolean { export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient'; -export type UndoPrecheck = - | { readonly ok: true } - | { - readonly ok: false; - readonly reason: UndoUnavailableReason; - readonly requested: number; - readonly undoable: number; - }; - -export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck { - const cut = computeUndoCut(history, count); - if (isFullyUndoable(cut, count)) return { ok: true }; - const reason: UndoUnavailableReason = cut.stoppedAtCompaction - ? 'compaction_boundary' - : cut.removedCount === 0 - ? 'empty' - : 'insufficient'; - return { ok: false, reason, requested: count, undoable: cut.removedCount }; -} - -export function formatUndoUnavailableMessage( - precheck: Extract, -): string { - switch (precheck.reason) { +export function formatUndoUnavailableMessage(input: { + readonly reason: UndoUnavailableReason; + readonly requested: number; + readonly undoable: number; +}): string { + switch (input.reason) { case 'empty': return 'Nothing to undo: no user message to undo'; case 'compaction_boundary': 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`; + return `Nothing to undo: only ${input.undoable} of ${input.requested} requested turn(s) available`; } } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index c719356be6..fc5f2c2d34 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -31,7 +31,7 @@ */ import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; -import type { WireRecord } from '#/wire/record'; +import { isLogCutRecord, type WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, @@ -73,12 +73,63 @@ interface MutableEntry { } export function reduceContextTranscript(records: Iterable): ContextTranscript { - const reducer = createContextTranscriptReducer(); - for (const record of records) reducer.add(record); + // Two passes over the buffered input: `log.cut` control records invalidate + // the record range [target, cut position) (wire rewind semantics), so the + // effective record stream is only known once the cuts have been seen. + const buffered = [...records]; + const reducer = createRawContextTranscriptReducer(); + for (const record of effectiveWireRecords(buffered)) reducer.add(record); return reducer.result(); } export function createContextTranscriptReducer(): ContextTranscriptReducer { + // Streaming callers (`reducer.add` per record, one final `result()`) + // buffer transparently so `log.cut` ranges apply exactly like the + // one-shot `reduceContextTranscript` path. + const buffered: WireRecord[] = []; + return { + add(record: WireRecord): void { + buffered.push(record); + }, + result(): ContextTranscript { + return reduceContextTranscript(buffered); + }, + }; +} + +/** + * The effective record stream after wire rewinds: every record whose + * (metadata-free) index falls outside every `log.cut` range + * `[target, cutPosition)`. Cut records themselves pass through (reducers + * ignore unknown types); malformed cuts consume their index but no range, + * matching the wire restore. + */ +function effectiveWireRecords(records: readonly WireRecord[]): Iterable { + const ranges: Array = []; + let index = 0; + for (const record of records) { + if (record.type === 'metadata') continue; + if (isLogCutRecord(record)) { + ranges.push([Math.min(record.target, index), index]); + } + index++; + } + if (ranges.length === 0) return records; + let outputIndex = 0; + const out: WireRecord[] = []; + for (const record of records) { + if (record.type === 'metadata') { + out.push(record); + continue; + } + const i = outputIndex++; + const invalid = ranges.some(([start, end]) => i >= start && i < end); + if (!invalid) out.push(record); + } + return out; +} + +function createRawContextTranscriptReducer(): ContextTranscriptReducer { const transcript: MutableEntry[] = []; let foldedLength = 0; let clearFloor = 0; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index 5a005ea283..c51905d126 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -24,6 +24,12 @@ export interface IAgentFullCompactionService { readonly compacting: FullCompactionTask | null; begin(input: FullCompactionInput): boolean; + /** + * Abort the in-flight compaction (if any) and wait for it to settle. + * A no-op when idle. Used by the rewind pipeline's quiesce step so a + * compaction can never apply a pre-rewind summary onto post-rewind context. + */ + cancel(): Promise; readonly hooks: Hooks<{ onWillCompact: FullCompactionTask; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index e89c49a6ab..0aeee5e592 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -270,6 +270,20 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull return true; } + async cancel(): Promise { + const active = this._compacting; + if (active === null) return; + if (!active.abortController.signal.aborted) { + active.abortController.abort(); + } + try { + await active.promise; + } catch { + // The abort path already routed through `cancelActive` (state cleanup + + // `compaction.cancelled`); worker rejections settle here by design. + } + } + private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { if (source === 'manual') { this.compactionCountInTurn = 0; diff --git a/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts b/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts new file mode 100644 index 0000000000..dabba1ee18 --- /dev/null +++ b/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts @@ -0,0 +1,42 @@ +/** + * `loop` domain (L4) — wire Model (`TurnIndexModel`) indexing user-turn + * boundaries and compaction points by journal position. + * + * Declares the rewind coordinate system for `IAgentRewindService`: the journal + * line index of every user-driven `turn.prompt` record (`turnStarts`) and of + * the most recent `context.apply_compaction` record (`lastCompactionIndex`, + * `-1` when none). "User-driven" reuses the single classification + * implementation (`compactionUserMessageDisposition`) — applied here, on the + * authoritative submission record, rather than by scanning history messages — + * so cron/goal/hook-initiated turns never become undo anchors. Both fields are + * derived through cross-model reducers fed with `OpApplyContext.recordIndex`, + * so the index requires no scanning at rewind time — and because the model is + * `rewindable`, a `log.cut` rebuild re-derives it from exactly the surviving + * records, keeping successive rewinds consistent. Consumed by the Agent-scope + * `rewindService`; not a user-facing surface. + */ + +import { compactionUserMessageDisposition } from '#/agent/contextMemory/compactionHandoff'; +import { defineModel } from '#/wire/model'; + +export interface TurnIndexState { + readonly turnStarts: readonly number[]; + readonly lastCompactionIndex: number; +} + +export const TurnIndexModel = defineModel( + 'turnIndex', + () => ({ turnStarts: [], lastCompactionIndex: -1 }), + { + rewindable: true, + reducers: { + 'turn.prompt': (state, payload, ctx) => + ctx?.recordIndex === undefined || + compactionUserMessageDisposition(payload.origin) !== 'keep' + ? state + : { ...state, turnStarts: [...state.turnStarts, ctx.recordIndex] }, + 'context.apply_compaction': (state, _payload, ctx) => + ctx?.recordIndex === undefined ? state : { ...state, lastCompactionIndex: ctx.recordIndex }, + }, + }, +); diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index 60e3dbd4f2..f8dd495217 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -28,7 +28,9 @@ export interface PlanState { readonly id?: string; } -export const PlanModel = defineModel('plan', () => ({ active: false })); +export const PlanModel = defineModel('plan', () => ({ active: false }), { + rewindable: true, +}); export const planModeEnter = PlanModel.defineOp('plan_mode.enter', { schema: z.object({ id: z.string() }), diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 8f9110a246..11748e4b1a 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -1,4 +1,5 @@ import { createDecorator } from '#/_base/di/instantiation'; +import type { IDisposable } from '#/_base/di/lifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Turn, TurnResult } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; @@ -55,8 +56,15 @@ export interface IAgentPromptService { abort(promptId: string, reason?: Error): boolean; inject(message: ContextMessage): Promise; retry(): Promise; - undo(count: number): number; clear(): void; + /** + * Suspend launching queued prompts until the returned handle is disposed + * (launching resumes, draining the queue, on the last release). The rewind + * pipeline holds this across its quiesce→cut window so an aborted active + * turn cannot auto-start the next queued prompt mid-rewind. Pending prompts + * stay queued. + */ + pauseLaunching(): IDisposable; readonly hooks: Hooks<{ onBeforeSubmitPrompt: PromptSubmitContext }>; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index f3bf39b49b..3902e1e6e3 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -9,16 +9,16 @@ import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; +import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; 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'; -import { steerTurn } from '#/agent/loop/turnOps'; +import { promptTurn, steerTurn } from '#/agent/loop/turnOps'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -63,6 +63,7 @@ export class AgentPromptService implements IAgentPromptService { private readonly pending: Record[] = []; private readonly steered = new Map(); private launching = false; + private pauseCount = 0; private fullCompactionService: IAgentFullCompactionService | undefined; readonly hooks = { onBeforeSubmitPrompt: new OrderedHookSlot() }; @@ -157,27 +158,36 @@ 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); this.context.clear(); } + pauseLaunching(): IDisposable { + this.pauseCount++; + let released = false; + return toDisposable(() => { + if (released) return; + released = true; + this.pauseCount--; + if (this.pauseCount === 0) void this.startNext(); + }); + } + private async startNext(): Promise { - if (this.active !== undefined || this.launching) return; + if (this.active !== undefined || this.launching || this.pauseCount > 0) return; const item = this.pending.shift(); if (item === undefined) return; this.launching = true; try { if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } const { message, captions } = this.extractCompressionCaptions(item.message); if (await this.blockedByHook(message, false)) { + // A hook-blocked prompt never starts a turn, but it IS a user prompt + // boundary in the journal — record `turn.prompt` so the rewind index + // sees it as an undo anchor (and `TurnModel` stays a faithful count + // of submitted prompts). + this.wire.dispatch(promptTurn({ input: message.content, origin: message.origin ?? USER_PROMPT_ORIGIN })); this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined); item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; diff --git a/packages/agent-core-v2/src/agent/rewind/rewind.ts b/packages/agent-core-v2/src/agent/rewind/rewind.ts new file mode 100644 index 0000000000..84d36041a7 --- /dev/null +++ b/packages/agent-core-v2/src/agent/rewind/rewind.ts @@ -0,0 +1,41 @@ +/** + * `rewind` domain (L6) — the single owner of the undo operation. + * + * `IAgentRewindService` turns "undo N user turns" into the rewind pipeline: + * quiesce (abort the active turn, cancel in-flight compaction, pause prompt + * launching) → precheck (turn boundaries from `TurnIndexModel`, compaction + * boundary) → `wire.rewind` (persist a `log.cut` control record and rebuild + * every rewindable model) → reconcile (context-size rebase, `lastPrompt`) → + * telemetry + `context.rewound`. Every entry point (REST `:undo`, RPC + * `undoHistory`, debug surface) converges here so the operation has exactly + * one guard, one error contract, and one telemetry call. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface RewindAvailability { + /** How many trailing user turns a rewind may cut (compaction-boundary aware). */ + readonly maxTurns: number; + /** Whether an uncompacted compaction record bounds the rewindable range. */ + readonly stoppedAtCompaction: boolean; +} + +export interface IAgentRewindService { + readonly _serviceBrand: undefined; + + /** Current rewind availability — the server-side source for undo selectors. */ + availability(): RewindAvailability; + + /** + * Undo the last `turns` user turns of this agent. Aborts an active turn and + * cancels an in-flight compaction first (quiesce), then rewinds every + * rewindable model to the Nth-to-last `turn.prompt` record. Pending prompt + * queue is preserved. Throws `session.undo_unavailable` (with a structured + * `reason`) when fewer than `turns` turns may be cut. Resolves to the number + * of turns actually cut. + */ + rewind(turns: number): Promise; +} + +export const IAgentRewindService: ServiceIdentifier = + createDecorator('agentRewindService'); diff --git a/packages/agent-core-v2/src/agent/rewind/rewindService.ts b/packages/agent-core-v2/src/agent/rewind/rewindService.ts new file mode 100644 index 0000000000..d8d5b090cb --- /dev/null +++ b/packages/agent-core-v2/src/agent/rewind/rewindService.ts @@ -0,0 +1,188 @@ +/** + * `rewind` domain (L6) — `IAgentRewindService` implementation. + * + * Pipeline (see `rewind.ts` for the contract): quiesce → precheck → + * `wire.rewind` → reconcile → telemetry/event. Quiesce pauses prompt + * launching (pending queue preserved), aborts the active turn and waits for + * the loop to settle, then cancels an in-flight compaction — so no producer + * can append records between the precheck and the cut, and no in-flight + * reader (a running turn's materialized context, a compaction's snapshot) + * can write pre-rewind results onto post-rewind state. Bound at Agent scope. + */ + +import { Disposable } from '#/_base/di/lifecycle'; +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { + formatUndoUnavailableMessage, + type UndoUnavailableReason, +} from '#/agent/contextMemory/contextOps'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; +import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; +import { IAgentLoopService } from '#/agent/loop/loop'; +import { TurnIndexModel } from '#/agent/loop/turnIndexOps'; +import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IEventService } from '#/app/event/event'; +import { IEventBus } from '#/app/event/eventBus'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ErrorCodes, Error2 } from '#/errors'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; +import { IWireService } from '#/wire/wire'; + +import { IAgentRewindService, type RewindAvailability } from './rewind'; + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + /** + * Published after a rewind cut has been applied live. Subscribers with + * history-derived bookkeeping (injection positions, pending tool loads, + * task-notification delivery mirrors) re-derive from the rebuilt models. + */ + 'context.rewound': { target: number; turns: number }; + } +} + +export class AgentRewindService extends Disposable implements IAgentRewindService { + declare readonly _serviceBrand: undefined; + + constructor( + @IWireService private readonly wire: IWireService, + @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, + @IAgentPromptService private readonly prompt: IAgentPromptService, + @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @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, + ) { + super(); + } + + availability(): RewindAvailability { + const { turnStarts, lastCompactionIndex } = this.wire.getModel(TurnIndexModel); + let maxTurns = 0; + for (const start of turnStarts) { + if (start > lastCompactionIndex) maxTurns++; + } + return { maxTurns, stoppedAtCompaction: lastCompactionIndex >= 0 }; + } + + async rewind(turns: number): Promise { + if (turns <= 0) return 0; + const pause = this.prompt.pauseLaunching(); + try { + // Quiesce: abort the active turn (its abort propagates into a blocking + // compaction wait) and wait for the loop to drain, then cancel any + // remaining in-flight compaction. No records are appended afterwards + // until the cut lands. + if (this.loop.status().state === 'running') { + this.loop.cancel(this.loop.status().activeTurnId); + } + await this.loop.settled(); + await this.fullCompaction.cancel(); + + const target = this.precheckTarget(turns); + await this.wire.rewind(target, 'undo'); + this.rebaseMeasuredTokens(); + await this.reconcileLastPrompt(); + this.telemetry.track2('conversation_undo', { count: turns }); + this.eventBus.publish({ type: 'context.rewound', target, turns }); + return turns; + } finally { + pause.dispose(); + } + } + + /** + * The cut point is the journal line of the Nth-to-last `turn.prompt` + * record, restricted to turns after the most recent compaction (the + * product-level compaction boundary; the wire protocol itself could cut + * across it). Throws `session.undo_unavailable` when unsatisfiable. + */ + private precheckTarget(turns: number): number { + const { turnStarts, lastCompactionIndex } = this.wire.getModel(TurnIndexModel); + const eligible: number[] = []; + for (const start of turnStarts) { + if (start > lastCompactionIndex) eligible.push(start); + } + if (eligible.length >= turns) { + return eligible[eligible.length - turns]!; + } + const reason: UndoUnavailableReason = + eligible.length === 0 + ? lastCompactionIndex >= 0 + ? 'compaction_boundary' + : 'empty' + : lastCompactionIndex >= 0 + ? 'compaction_boundary' + : 'insufficient'; + throw new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage({ reason, requested: turns, undoable: eligible.length }), + { details: { reason, requestedCount: turns, undoableCount: eligible.length } }, + ); + } + + /** + * The measured token prefix is a live-only (transient) model, so the rewind + * cannot rebuild it; when the cut truncates the measured prefix, rebase it + * to an estimate over the surviving history (the pre-rewind `undo` + * behavior, centralized here). + */ + private rebaseMeasuredTokens(): void { + const surviving = this.context.get(); + const measured = this.wire.getModel(ContextSizeModel); + if (measured.length <= surviving.length) return; + this.wire.dispatch( + contextSizeMeasured({ + length: surviving.length, + tokens: estimateTokensForMessages(surviving), + }), + ); + } + + /** Best-effort `lastPrompt` reconcile: adopt the last surviving real user + * prompt; leave the field untouched when none survives. Titles are never + * rewritten here (custom or auto). */ + private async reconcileLastPrompt(): Promise { + const history = this.context.get(); + for (let i = history.length - 1; i >= 0; i--) { + const message = history[i]!; + if (!isUserPromptMessage(message)) continue; + const text = message.content + .filter((part) => part.type === 'text') + .map((part) => ('text' in part ? part.text : '')) + .join('\n'); + await this.metadata.update({ lastPrompt: text }); + this.eventService.publish({ + type: 'session.meta.updated', + payload: { + agentId: 'main', + sessionId: this.session.sessionId, + patch: { lastPrompt: text }, + }, + }); + return; + } + } +} + +function isUserPromptMessage(message: ContextMessage): boolean { + if (message.role !== 'user') return false; + const origin = message.origin; + return origin === undefined || origin.kind === 'user'; +} + +registerScopedService( + LifecycleScope.Agent, + IAgentRewindService, + AgentRewindService, + InstantiationType.Eager, + 'rewind', +); 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 cfa198b40a..ae0e878f51 100644 --- a/packages/agent-core-v2/src/agent/rpc/core-api.ts +++ b/packages/agent-core-v2/src/agent/rpc/core-api.ts @@ -313,7 +313,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/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index cd27ffbea8..0584e11431 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -19,6 +19,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 { IAgentRewindService } from '#/agent/rewind/rewind'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAgentSkillService } from '#/agent/skill/skill'; @@ -64,6 +65,7 @@ export class AgentRPCService implements IAgentRPCService { constructor( @IAgentPromptService private readonly promptService: IAgentPromptService, + @IAgentRewindService private readonly rewind: IAgentRewindService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @@ -127,10 +129,10 @@ 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 { + // The rewind service owns the operation end-to-end (quiesce, cut, + // reconcile, telemetry) — this is a thin pass-through. + return this.rewind.rewind(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 ff59ca0bf1..786bc52da3 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -111,6 +111,9 @@ const TaskNotificationDeliveryModel = defineModel( 'task.notificationDelivery', () => [], { + // Rewindable: a rewind drops the delivery marks of notifications the cut + // removed from the context, so they re-deliver on the next inject. + rewindable: true, reducers: { 'context.append_message': (state, payload: { message?: unknown }) => { const origin = taskOriginFromMessage(payload.message); @@ -266,6 +269,16 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } }), ); + this._register( + this.eventBus.subscribe('context.rewound', () => { + // The delivery model was rebuilt by the rewind; re-sync the live + // mirror so notifications cut from the context re-deliver. + this.deliveredNotificationKeys.clear(); + for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) { + this.deliveredNotificationKeys.add(key); + } + }), + ); this._register( injector.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => this.activeBackgroundTaskReminder(), diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 39dbc581f4..6b5dd9b867 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -66,12 +66,24 @@ 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(); }), ); + this._register( + eventBus.subscribe('context.rewound', () => { + // Same re-derivation as the splice path: pending loaded tools whose + // landing messages the rewind removed must not stick. + this.dropPendingLoadedNotLanded(); + }), + ); + } + + 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 { diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6f833e0b32..5297a92ef2 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -454,6 +454,8 @@ import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; export * from '#/agent/replayBuilder/types'; +export * from '#/agent/rewind/rewind'; +export * from '#/agent/rewind/rewindService'; 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/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 29a931aa4b..7016781b94 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -25,7 +25,10 @@ import { readTodoItems, type TodoItem } from './todoItem'; export type TodoModelState = readonly TodoItem[]; -export const TodoModel = defineModel('todo', () => []); +export const TodoModel = defineModel('todo', () => [], { + // Rewindable: todos written by an undone turn disappear with it. + rewindable: true, +}); declare module '#/wire/types' { interface PersistedOpMap { diff --git a/packages/agent-core-v2/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts index 30e934b06f..8b77822990 100644 --- a/packages/agent-core-v2/src/wire/errors.ts +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -17,6 +17,7 @@ export const WireErrors = { WIRE_DUPLICATE_OP: 'wire.duplicate_op', WIRE_CYCLE: 'wire.cycle', WIRE_UNKNOWN_RECORD: 'wire.unknown_record', + WIRE_INVALID_REWIND_TARGET: 'wire.invalid_rewind_target', RECORDS_WRITE_FAILED: 'records.write_failed', }, info: { @@ -38,6 +39,12 @@ export const WireErrors = { public: true, action: 'The record was written by a newer version; upgrade or drop it.', }, + 'wire.invalid_rewind_target': { + title: 'Invalid wire rewind target', + retryable: false, + public: true, + action: 'The log.cut target is outside the journal bounds; recompute it from the current journal length.', + }, 'records.write_failed': { title: 'Wire journal write failed', retryable: false, diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index cf22f3ef35..04cac70106 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -39,7 +39,7 @@ * applied by `WireService` after every `apply`. Scope-agnostic. */ -import { bindDefineOp, type DefineOpFn } from '#/wire/op'; +import { bindDefineOp, type DefineOpFn, type OpApplyContext } from '#/wire/op'; import type { ModelReducers } from '#/wire/types'; import type { WireRecord } from '#/wire/record'; @@ -54,14 +54,27 @@ export interface ModelDef { readonly name: string; readonly initial: () => S; readonly blobs?: ModelBlobCodec; + /** + * Temporal classification of the model's state: `true` marks state that + * belongs to conversation time and is rebuilt by `log.cut` rewinds + * (`IWireService.rewind`); absent/`false` marks world-time state (usage, + * task registries, turn counters) that survives rewinds untouched. + */ + readonly rewindable?: boolean; readonly defineOp: DefineOpFn; } export interface ModelCrossReducerEntry { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly model: ModelDef; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - readonly reducer: (state: any, payload: any) => any; + readonly reducer: ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + state: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + ctx?: OpApplyContext, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ) => any; } export const MODEL_CROSS_REDUCERS = new Map(); @@ -72,12 +85,14 @@ export function defineModel( opts?: { blobs?: ModelBlobCodec; reducers?: ModelReducers; + rewindable?: boolean; }, ): ModelDef { const def: ModelDef = { name, initial, blobs: opts?.blobs, + rewindable: opts?.rewindable, defineOp: bindDefineOp(() => def), }; if (opts?.reducers !== undefined) { diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index d635ddb5d0..8edfa9979d 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -42,11 +42,25 @@ export class DuplicateOpError extends WireError { } } +/** + * Context handed to `apply` / cross-model reducers alongside the payload. + * `recordIndex` is the Op's own position in the journal (0-based over + * NON-metadata records): assigned by `WireService` on live dispatch for + * persisted Ops, and supplied from the replay cursor during restore. It is + * `undefined` for transient (`persist: false`) Ops on the live path — they + * never occupy a journal line. Ops that only transform state ignore it; Ops + * that index journal positions (e.g. turn-boundary indexes feeding `log.cut` + * targets) require it. + */ +export interface OpApplyContext { + readonly recordIndex?: number; +} + export interface OpDescriptor { readonly type: K; readonly model: ModelDef; readonly schema: z.ZodType

; - readonly apply: (state: S, payload: P) => S; + readonly apply: (state: S, payload: P, ctx?: OpApplyContext) => S; readonly toEvent?: (payload: P, state: S) => unknown; readonly persist?: boolean; } @@ -54,6 +68,12 @@ export interface OpDescriptor { export interface Op { readonly type: K; readonly payload: P; + /** + * Journal position of this Op's record, set only by `WireService` internals + * (restore replay and, for persisted Ops, live dispatch). Never set by + * callers of `dispatch`. + */ + readonly recordIndex?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly descriptor: OpDescriptor; } @@ -63,7 +83,7 @@ export const OP_REGISTRY = new Map>(); interface OpBehaviorOptions { readonly schema: z.ZodType

; - readonly apply: (state: S, payload: P) => S; + readonly apply: (state: S, payload: P, ctx?: OpApplyContext) => S; readonly toEvent?: (payload: P, state: S) => unknown; } diff --git a/packages/agent-core-v2/src/wire/record.ts b/packages/agent-core-v2/src/wire/record.ts index 92a9bc28f9..d65cdc5ef0 100644 --- a/packages/agent-core-v2/src/wire/record.ts +++ b/packages/agent-core-v2/src/wire/record.ts @@ -25,6 +25,42 @@ export interface WireMetadataRecord extends WireRecord { readonly created_at: number; } +/** + * `log.cut` — the wire layer's rewind control record. NOT an Op: it never + * enters `OP_REGISTRY` or any business model. `WireService` interprets it + * during restore and writes it from `rewind()`. `target` is a record index + * (0-based over NON-metadata records — the metadata envelope never occupies + * an index, so migration rewrites that add it never shift targets): on + * encountering the record, every `rewindable` model is reset to the fold of + * records `[0, target)`, and replay then continues after this record. + * `reason` is audit metadata (e.g. 'undo'). + */ +export const LOG_CUT_RECORD_TYPE = 'log.cut'; + +export interface LogCutRecord extends WireRecord { + readonly type: typeof LOG_CUT_RECORD_TYPE; + readonly target: number; + readonly reason?: string; +} + +export function isLogCutRecord(record: WireRecord): record is LogCutRecord { + return ( + record.type === LOG_CUT_RECORD_TYPE && + typeof record['target'] === 'number' && + Number.isInteger(record['target']) && + record['target'] >= 0 + ); +} + +export function createLogCutRecord(target: number, reason?: string, now = Date.now()): LogCutRecord { + return { + type: LOG_CUT_RECORD_TYPE, + target, + ...(reason !== undefined ? { reason } : {}), + time: now, + }; +} + export function isWireRecord(record: unknown): record is WireRecord { return ( record !== null && diff --git a/packages/agent-core-v2/src/wire/types.ts b/packages/agent-core-v2/src/wire/types.ts index f084c312b0..66a0f4a197 100644 --- a/packages/agent-core-v2/src/wire/types.ts +++ b/packages/agent-core-v2/src/wire/types.ts @@ -34,7 +34,11 @@ export type OpPayload = K extends PersistedOpType : never; export type ModelReducers = { - [K in OpType]?: (state: S, payload: OpPayload) => S; + [K in OpType]?: ( + state: S, + payload: OpPayload, + ctx?: { readonly recordIndex?: number }, + ) => S; }; export type OpPersistenceOptions = K extends PersistedOpType diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts index 86e36fd66c..3d05619008 100644 --- a/packages/agent-core-v2/src/wire/wire.ts +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -28,6 +28,13 @@ export interface IWireService { seal(): Promise; restore(): Promise; flush(): Promise; + /** + * Rewind every `rewindable` model to the fold of journal records + * `[0, target)` and append a `log.cut` control record. `target` is a + * journal line index (0-based, metadata line included). Callers must + * quiesce record producers (loop, compaction) before rewinding. + */ + rewind(target: number, reason?: string): Promise; getModel(model: ModelDef): DeepReadonly; } diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index e2eec95dce..2629a27095 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -36,11 +36,13 @@ import { } from './migration/migration'; import type { DeepReadonly, ModelDef, PartsTransformer } from './model'; import { MODEL_CROSS_REDUCERS } from './model'; -import type { Op } from './op'; +import type { Op, OpApplyContext } from './op'; import { OP_REGISTRY } from './op'; import { AGENT_WIRE_RECORD_KEY, + createLogCutRecord, createWireMetadataRecord, + isLogCutRecord, isWireRecord, isWireMetadataRecord, opToWireRecord, @@ -84,9 +86,18 @@ export class WireService extends Disposable implements IWireService { private restorePhase: RestorePhase = 'new'; private dispatching = false; + private rewinding = false; private queue: Op[] = []; private drainDepth = 0; private persistQueue: Promise | undefined; + /** + * Cursor over NON-metadata journal records (0-based): the index the next + * appended data record will occupy in the metadata-free record coordinate + * system shared by `log.cut` targets and `OpApplyContext.recordIndex`. + * The metadata envelope line never consumes an index, so adding or + * removing it (seal / migration rewrite) never shifts record positions. + */ + private nextRecordIndex = 0; constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @@ -105,6 +116,9 @@ export class WireService extends Disposable implements IWireService { dispatch(...ops: Op[]): void { if (ops.length === 0) return; + if (this.rewinding) { + throw new BugIndicatingError('Wire dispatch during an in-flight rewind is not allowed'); + } if (this.dispatching) { this.queue.push(...ops); return; @@ -147,6 +161,8 @@ export class WireService extends Disposable implements IWireService { let migrations: readonly WireMigration[] = []; let rewrittenRecords: WireRecord[] | undefined; let newerWireVersion = false; + // Cursor over NON-metadata records — the metadata-free coordinate + // system `log.cut` targets and `OpApplyContext.recordIndex` share. let recordIndex = 0; let hasRecords = false; @@ -185,6 +201,17 @@ export class WireService extends Disposable implements IWireService { : migratedRecord; rewrittenRecords?.push(record); if (record.type === 'metadata') continue; + if (record.type === 'log.cut') { + // Wire-layer control record: rewind every rewindable model to the + // fold of records [0, target), then continue replay after it. + if (isLogCutRecord(record)) { + await this.rebuildRewindableModels(Math.min(record.target, recordIndex), migrations); + } else { + this.reportSkippedRecord(record.type, recordIndex, true); + } + recordIndex++; + continue; + } this.replayRecord(record, recordIndex); recordIndex++; @@ -193,6 +220,7 @@ export class WireService extends Disposable implements IWireService { if (!hasRecords) { rewrittenRecords = [createWireMetadataRecord()]; } + this.nextRecordIndex = recordIndex; if (rewrittenRecords !== undefined) { await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords); } @@ -211,6 +239,111 @@ export class WireService extends Disposable implements IWireService { await this.log.flush(); } + /** + * Rewind every `rewindable` model to the fold of journal records + * `[0, target)`, then append a `log.cut` control record marking the rewind. + * Non-rewindable (world-time) models keep their current state. Live and + * restore share the same rebuild path (`rebuildRewindableModels`), so the + * post-rewind state is by construction identical to a fresh replay of the + * journal up to the cut. Dispatches must not interleave with a rewind — + * callers quiesce producers (loop, compaction) first; any dispatch arriving + * while a rewind is in flight throws. + */ + async rewind(target: number, reason?: string): Promise { + if (this.restorePhase === 'restoring' || this.restorePhase === 'failed') { + throw new BugIndicatingError(`Wire rewind called while phase is ${this.restorePhase}`); + } + if (this.dispatching || this.rewinding) { + throw new BugIndicatingError('Wire rewind re-entered while dispatching or rewinding'); + } + if (!Number.isInteger(target) || target < 0 || target > this.nextRecordIndex) { + throw new WireError(WireErrors.codes.WIRE_INVALID_REWIND_TARGET, `Invalid rewind target ${String(target)}`, { + details: { target, nextRecordIndex: this.nextRecordIndex }, + }); + } + this.rewinding = true; + try { + // Drain the persist queue so every record below `target` is durable + // before the rebuild reads the journal back. + await this.flush(); + await this.rebuildRewindableModels(target, []); + this.appendRecord(createLogCutRecord(target, reason)); + this.nextRecordIndex++; + } finally { + this.rewinding = false; + } + } + + /** + * Reset all rewindable models to their initial state and re-apply journal + * records `[0, target)` to them only (cross-model reducers included; records + * routing to non-rewindable models contribute cross effects but no state). + * Nested `log.cut` records inside the range recurse — the cut's own + * semantics is "rewindable models := fold([0, cut.target))", and replay + * continues after the cut record, exactly like the restore main loop. + * `migrations` lets the restore-time rebuild see the same record view as + * the main replay when the on-disk file predates the current protocol. + */ + private async rebuildRewindableModels( + target: number, + migrations: readonly WireMigration[], + ): Promise { + for (const [def, inst] of this.models) { + if (def.rewindable === true) { + inst.state = Object.freeze(def.initial()); + } + } + let index = 0; + const source = this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY); + for await (const candidate of source) { + if (index >= target) break; + if (!isWireRecord(candidate)) { + index++; + continue; + } + // The metadata envelope never consumes a record index (see + // `nextRecordIndex`), so metadata-less and metadata-ful journals index + // identically. + if (candidate.type === 'metadata') continue; + const record = migrateWireRecord(candidate, migrations); + if (record.type === 'log.cut') { + if (isLogCutRecord(record)) { + await this.rebuildRewindableModels(Math.min(record.target, index), migrations); + } + index++; + continue; + } + this.replayRewindableRecord(record, index); + index++; + } + await this.rehydrateModels(); + } + + /** + * Re-apply one record during a rewind rebuild: the owning model is updated + * only when rewindable; cross-model reducers run only for rewindable + * targets, so non-rewindable (world-time) models are never double-applied. + */ + private replayRewindableRecord(record: WireRecord, index: number): void { + const descriptor = OP_REGISTRY.get(record.type); + if (descriptor === undefined) return; + const payload = descriptor.schema.safeParse(wireRecordToPayload(record)); + if (!payload.success) return; + const ctx: OpApplyContext = { recordIndex: index }; + if (descriptor.model.rewindable === true) { + const inst = this.ensureModel(descriptor.model); + inst.state = Object.freeze(descriptor.apply(inst.state, payload.data, ctx)); + } + const crossReducers = MODEL_CROSS_REDUCERS.get(record.type); + if (crossReducers !== undefined) { + for (const entry of crossReducers) { + if (entry.model.rewindable !== true) continue; + const crossInst = this.ensureModel(entry.model); + crossInst.state = Object.freeze(entry.reducer(crossInst.state, payload.data, ctx)); + } + } + } + private replayRecord(record: WireRecord, index: number): void { const descriptor = OP_REGISTRY.get(record.type); if (descriptor === undefined) { @@ -223,7 +356,7 @@ export class WireService extends Disposable implements IWireService { return; } this.execute({ - ops: [{ type: record.type, payload: payload.data, descriptor }], + ops: [{ type: record.type, payload: payload.data, descriptor, recordIndex: index }], silent: true, }); } @@ -246,7 +379,15 @@ export class WireService extends Disposable implements IWireService { for (const op of group.ops) { const inst = this.ensureModel(op.descriptor.model); const prev = inst.state; - inst.state = Object.freeze(op.descriptor.apply(prev, op.payload)); + // Journal position of this op's record: replay supplies it; live + // dispatch assigns the next line index for persisted ops. Transient ops + // never occupy a journal line and keep `undefined`. + let recordIndex = op.recordIndex; + if (!group.silent && recordIndex === undefined && op.descriptor.persist !== false) { + recordIndex = this.nextRecordIndex++; + } + const ctx: OpApplyContext = { recordIndex }; + inst.state = Object.freeze(op.descriptor.apply(prev, op.payload, ctx)); if (!group.silent) { if (op.descriptor.persist !== false) { const record = opToWireRecord(op); @@ -262,7 +403,7 @@ export class WireService extends Disposable implements IWireService { for (const entry of crossReducers) { if (entry.model === op.descriptor.model) continue; const crossInst = this.ensureModel(entry.model); - crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload)); + crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload, ctx)); } } } 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..0111cbd06f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/context.test.ts @@ -579,12 +579,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 +592,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 +629,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 +652,7 @@ describe('Agent context', () => { }, ); - ctx.undoHistory(1); + await ctx.undoHistory(1); expect(context.get()).toEqual([ expect.objectContaining({ 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..ee6791d324 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -274,3 +274,44 @@ describe('reduceContextTranscript', () => { expect(result.foldedLength).toBe(4); }); }); + +describe('reduceContextTranscript — log.cut (wire rewind)', () => { + it('drops the record range invalidated by a log.cut', () => { + // Journal: prompt(0), u1(1), a1(2..4), prompt(5), u2(6), a2(7..9), cut(10→5) + const result = reduceContextTranscript([ + { type: 'turn.prompt', input: [{ type: 'text', text: 'u1' }], origin: { kind: 'user' } }, + appendMessage(userMessage('u1')), + ...assistantStep('s1', 'a1'), + { type: 'turn.prompt', input: [{ type: 'text', text: 'u2' }], origin: { kind: 'user' } }, + appendMessage(userMessage('u2')), + ...assistantStep('s2', 'a2'), + { type: 'log.cut', target: 5, reason: 'undo' }, + ]); + expect(texts(result)).toEqual(['u1', 'a1']); + expect(result.foldedLength).toBe(2); + }); + + it('ignores the metadata envelope without giving it a record index', () => { + const result = reduceContextTranscript([ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + appendMessage(userMessage('u1')), + appendMessage(userMessage('u2')), + { type: 'log.cut', target: 1, reason: 'undo' }, + ]); + expect(texts(result)).toEqual(['u1']); + }); + + it('applies nested cuts and keeps records appended after them', () => { + // u2 invalidated by the first cut; u3/u4 by the second; cuts compose. + const result = reduceContextTranscript([ + appendMessage(userMessage('u1')), + appendMessage(userMessage('u2')), + { type: 'log.cut', target: 1, reason: 'undo' }, + appendMessage(userMessage('u3')), + appendMessage(userMessage('u4')), + { type: 'log.cut', target: 3, reason: 'undo' }, + appendMessage(userMessage('u5')), + ]); + expect(texts(result)).toEqual(['u1', 'u5']); + }); +}); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index ba6aef5624..5409f30643 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -14,7 +14,6 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; -import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; @@ -59,15 +58,6 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { messages.splice(0, deleteCount); publishSplice(eventBus, { start: 0, deleteCount, messages: [] }); }, - undo: (count) => { - const cut = computeUndoCut(messages, count); - if (cut.cutIndex >= 0 && cut.removedCount >= count) { - const deleteCount = messages.length - cut.cutIndex; - messages.splice(cut.cutIndex, deleteCount); - publishSplice(eventBus, { start: cut.cutIndex, deleteCount, messages: [] }); - } - return cut; - }, applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => { const shape = buildContextCompactionShape(messages, input); const previousLength = messages.length; @@ -106,9 +96,6 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } - undo(count: number): UndoCut { - return this.impl.undo(count); - } applyCompaction(input: ContextCompactionInput): ContextCompactionResult { return this.impl.applyCompaction(input); } 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..9c89b13ee4 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,83 @@ 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 }); +// `computeUndoCut` / `isFullyUndoable` / `contextUndo` are the LEGACY undo +// semantics, kept only so journals with historical `context.undo` records +// still replay. Live undos go through `rewind` (`log.cut`); see +// `test/agent/rewind/`. +describe('computeUndoCut (legacy context.undo replay semantics)', () => { + 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 (legacy record apply)', () => { + 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('returns the same reference for a non-positive count', () => { + const state = [user(USER_ORIGIN), assistant()]; + expect(contextUndo.apply(state, { count: 0 })).toBe(state); }); }); 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 ca7fa17992..1599260a6d 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -752,11 +752,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/rewind/rewind.test.ts b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts new file mode 100644 index 0000000000..17681f9642 --- /dev/null +++ b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentPlanService } from '#/agent/plan/plan'; +import { PlanModel } from '#/agent/plan/planOps'; +import { IAgentRewindService } from '#/agent/rewind/rewind'; +import { ErrorCodes } from '#/errors'; +import { TodoModel, todoSet } from '#/session/todo/todoOps'; +import { IWireService } from '#/wire/wire'; + +import { createTestAgent, telemetryServices, type TestAgentContext } from '../../harness'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; + +describe('AgentRewindService', () => { + let ctx: TestAgentContext; + let records: TelemetryRecord[]; + let rewoundEvents: number; + + afterEach(async () => { + try { + await ctx.expectResumeMatches(); + } finally { + await ctx.dispose(); + } + }); + + function setup() { + records = []; + rewoundEvents = 0; + ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); + ctx.get(IAgentContextMemoryService); + return ctx; + } + + it('exposes availability from the turn index', async () => { + setup(); + const rewind = ctx.get(IAgentRewindService); + expect(rewind.availability()).toEqual({ maxTurns: 0, stoppedAtCompaction: false }); + + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + expect(rewind.availability()).toEqual({ maxTurns: 2, stoppedAtCompaction: false }); + }); + + it('rejects undo with structured reasons', async () => { + setup(); + const rewind = ctx.get(IAgentRewindService); + + await expect(rewind.rewind(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'empty', requestedCount: 1, undoableCount: 0 }, + }); + + ctx.appendTurnExchange('u1', 'a1'); + await expect(rewind.rewind(2)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'insufficient', requestedCount: 2, undoableCount: 1 }, + }); + }); + + it('refuses to cross a compaction boundary', async () => { + setup(); + const rewind = ctx.get(IAgentRewindService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.get(IAgentContextMemoryService).applyCompaction({ + summary: 'summary of u1', + compactedCount: 2, + tokensBefore: 100, + tokensAfter: 10, + }); + ctx.appendTurnExchange('u2', 'a2'); + + expect(rewind.availability()).toEqual({ maxTurns: 1, stoppedAtCompaction: true }); + await expect(rewind.rewind(2)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'compaction_boundary', requestedCount: 2, undoableCount: 1 }, + }); + + await rewind.rewind(1); + // The compaction shape keeps the recent user message plus the summary; + // turn 2 is gone. + const history = ctx.context.get(); + expect(history.map((m) => m.role)).toEqual(['user', 'user']); + expect(history[1]?.origin?.kind).toBe('compaction_summary'); + }); + + it('rewinds todos and plan mode together with the undone turns', async () => { + setup(); + const rewind = ctx.get(IAgentRewindService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + // The session todo facade is not wired to the harness agent lifecycle; + // dispatch the todo ops directly (the facade's own target wire anyway). + 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 ctx.get(IAgentPlanService).enter('plan-x', false); + + await rewind.rewind(1); + + expect(wire.getModel(TodoModel)).toEqual([{ title: 'kept', status: 'pending' }]); + expect(wire.getModel(PlanModel).active).toBe(false); + }); + + it('publishes context.rewound and tracks conversation_undo', async () => { + setup(); + ctx.get(IAgentRewindService); + 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']); + }); +}); 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..cbb6a0714e 100644 --- a/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts +++ b/packages/agent-core-v2/test/agent/rpc/undoHistory.test.ts @@ -22,7 +22,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 }); 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 9c5dbfa54f..888ce76df1 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/taskService.test.ts b/packages/agent-core-v2/test/agent/task/taskService.test.ts index f873dfeb97..11eeaf7a1b 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -71,6 +71,7 @@ function stubWireService(captureRestoreHook?: (hook: RestoreHook) => void): IWir seal: async () => {}, restore: async () => {}, flush: async () => {}, + rewind: async () => {}, getModel: (model) => model.initial() as never, subscribe: () => toDisposable(() => {}), } as IWireService; 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 db6e07250f..b53ded8f60 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 { IAgentRewindService } from '#/agent/rewind/rewind'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { ExecutableTool, ToolExecution } from '#/tool/toolContract'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; @@ -185,7 +186,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(IAgentRewindService).rewind(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/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index 43bcbcabbb..19578b94e3 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -19,7 +19,6 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; -import { computeUndoCut } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { HookDefSchema, @@ -103,13 +102,6 @@ function stubContextMemory(): IAgentContextMemoryService & { clear: () => { messages.splice(0); }, - undo: (count) => { - const cut = computeUndoCut(messages, count); - if (cut.cutIndex >= 0 && cut.removedCount >= count) { - messages.splice(cut.cutIndex, messages.length - cut.cutIndex); - } - return cut; - }, applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => { const shape = buildContextCompactionShape(messages, input); messages.splice(0, messages.length, ...shape.messages); 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..5a711cb608 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -56,8 +56,8 @@ describe('RestGateway', () => { abort: () => true, inject: () => Promise.resolve(undefined), retry: () => Promise.resolve(undefined), - undo: () => 0, clear: () => {}, + pauseLaunching: () => ({ dispose: () => {} }), 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 d27b01575f..dd072576d0 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -145,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 } from '#/kosong/model/model'; import { IModelCatalog, type Model } from '#/kosong/model/catalog'; import { ModelCatalog } from '#/kosong/model/catalogService'; @@ -844,6 +845,7 @@ function collectScopeSeed( class PersistenceAppendLogStore implements IAppendLogStore { declare readonly _serviceBrand: undefined; private readonly history: WireRecord[] = []; + private historyReadCursor = 0; constructor( private readonly persistence: WireRecordPersistence, @@ -861,7 +863,16 @@ class PersistenceAppendLogStore implements IAppendLogStore { async *read(_scope: string, _key: string): AsyncIterable { for await (const event of this.persistence.read()) { this.onRead(event); - this.history.push(cloneRecord(event)); + // Capture records entering the journal via reads (seeds injected + // directly into persistence, then surfaced by a restore) WITHOUT + // duplicating records already in the append history at this position — + // a mid-flight re-read of an append-only journal (e.g. `wire.rewind`'s + // rebuild) would otherwise seed resume assertions with duplicates. + const existing = this.history[this.historyReadCursor]; + if (existing === undefined || JSON.stringify(existing) !== JSON.stringify(event)) { + this.history.push(cloneRecord(event)); + } + this.historyReadCursor++; yield event as R; } } @@ -1383,6 +1394,24 @@ export class AgentTestContext { }); } + /** + * Append a user prompt the way production does: a `turn.prompt` boundary + * record followed by the user message. Undo/rewind tests must build turns + * through this helper — bare `appendUserMessage` appends have no boundary + * and are invisible to the rewind index. + */ + 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' }, @@ -1413,9 +1442,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 { @@ -1502,6 +1531,20 @@ export class AgentTestContext { this.coverUsage(tokenTotal); } + /** + * `appendExchange` with a real `turn.prompt` boundary (see + * `appendUserTurn`): the shape undo/rewind tests need. + */ + 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); } diff --git a/packages/agent-core-v2/test/wire/rewind.test.ts b/packages/agent-core-v2/test/wire/rewind.test.ts new file mode 100644 index 0000000000..882b00dc86 --- /dev/null +++ b/packages/agent-core-v2/test/wire/rewind.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; + +import { createServices } from '#/_base/di/test'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { contextAppendMessage, ContextModel } from '#/agent/contextMemory/contextOps'; +import type { ContextMessage } from '#/agent/contextMemory/types'; +import { TurnModel } from '#/agent/loop/turnOps'; +import { promptTurn } from '#/agent/loop/turnOps'; +import { TurnIndexModel } from '#/agent/loop/turnIndexOps'; +import { TodoModel, todoSet } from '#/session/todo/todoOps'; +import { WireError } from '#/wire/errors'; +import { LOG_CUT_RECORD_TYPE, type WireRecord } from '#/wire/record'; + +import { recordingWireLog, registerTestAgentWire } from './stubs'; + +const SCOPE = 'wire/rewind-test'; + +function userMessage(text: string): ContextMessage { + return { + role: 'user', + content: [{ type: 'text', text }], + toolCalls: [], + origin: { kind: 'user' }, + }; +} + +function promptRecord(text: string) { + return promptTurn({ input: [{ type: 'text', text }], origin: { kind: 'user' } }); +} + +function buildHost(records: WireRecord[] = []) { + const disposables = new DisposableStore(); + const ix = createServices(disposables); + const wire = registerTestAgentWire(ix, SCOPE, { log: recordingWireLog(records) }); + return { disposables, ix, wire, records }; +} + +describe('WireService rewind (log.cut)', () => { + it('rebuilds rewindable models to the cut and preserves world-time models', async () => { + const { wire, records } = buildHost(); + // Journal: turn.prompt(0), user(1), todo(2), turn.prompt(3), user(4), todo(5) + wire.dispatch(promptRecord('u1')); + wire.dispatch(contextAppendMessage({ message: userMessage('u1') })); + wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'from turn 1', status: 'pending' }] })); + wire.dispatch(promptRecord('u2')); + wire.dispatch(contextAppendMessage({ message: userMessage('u2') })); + wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'from turn 2', status: 'pending' }] })); + + // Cut at the second turn's prompt (record index 3). + await wire.rewind(3, 'undo'); + + // Rewindable: context and todo rebuilt from [0, 3). + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); + expect(wire.getModel(TodoModel)).toEqual([{ title: 'from turn 1', status: 'pending' }]); + expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); + // World-time: the turn counter never rewinds. + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); + // The cut record was appended and occupies the next index. + expect(records.map((r) => r.type)).toEqual([ + 'turn.prompt', + 'context.append_message', + 'tools.update_store', + 'turn.prompt', + 'context.append_message', + 'tools.update_store', + LOG_CUT_RECORD_TYPE, + ]); + expect(records[6]).toMatchObject({ target: 3, reason: 'undo' }); + + // State after the cut accepts new turns on top of the rebuilt base. + wire.dispatch(promptRecord('u3')); + wire.dispatch(contextAppendMessage({ message: userMessage('u3') })); + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1'), userMessage('u3')]); + expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0, 7]); + }); + + it('restore of a journal with log.cut produces the post-rewind state', async () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { type: 'turn.prompt', input: [{ type: 'text', text: 'u1' }], origin: { kind: 'user' } }, + { type: 'context.append_message', message: userMessage('u1') }, + { type: 'turn.prompt', input: [{ type: 'text', text: 'u2' }], origin: { kind: 'user' } }, + { type: 'context.append_message', message: userMessage('u2') }, + { type: 'log.cut', target: 2, reason: 'undo' }, + ]; + const { wire } = buildHost(records); + await wire.restore(); + + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); + expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); + }); + + it('handles nested cuts: a later cut whose range contains an earlier cut', async () => { + const { wire } = buildHost(); + wire.dispatch(promptRecord('u1')); + wire.dispatch(contextAppendMessage({ message: userMessage('u1') })); + wire.dispatch(promptRecord('u2')); + wire.dispatch(contextAppendMessage({ message: userMessage('u2') })); + // Cut away u2 (records 0..3, cut at 4 targeting 2). + await wire.rewind(2, 'undo'); + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); + + wire.dispatch(promptRecord('u3')); + wire.dispatch(contextAppendMessage({ message: userMessage('u3') })); + // Journal: prompt(0), user(1), prompt(2), user(3), cut(4→2), prompt(5), user(6) + // Cut away everything from the second turn's prompt onward (record 2). + await wire.rewind(2, 'undo'); + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); + expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); + }); + + it('still replays legacy context.undo records (pre-rewind journals)', async () => { + const records: WireRecord[] = [ + { type: 'metadata', protocol_version: '1.5', created_at: 1 }, + { type: 'context.append_message', message: userMessage('u1') }, + { type: 'context.append_message', message: userMessage('u2') }, + { type: 'context.undo', count: 1 }, + ]; + const { wire } = buildHost(records); + await wire.restore(); + expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); + }); + + it('rejects an out-of-bounds target and a re-entrant rewind', async () => { + const { wire } = buildHost(); + wire.dispatch(promptRecord('u1')); + + await expect(wire.rewind(-1)).rejects.toThrow(WireError); + await expect(wire.rewind(2)).rejects.toThrow(WireError); + + const first = wire.rewind(1, 'undo'); + await expect(wire.rewind(1, 'undo')).rejects.toThrow(/re-entered/); + await first; + expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); + }); +}); diff --git a/packages/agent-core-v2/test/wire/stubs.ts b/packages/agent-core-v2/test/wire/stubs.ts index 8c5dc22f36..f182ab0514 100644 --- a/packages/agent-core-v2/test/wire/stubs.ts +++ b/packages/agent-core-v2/test/wire/stubs.ts @@ -100,6 +100,7 @@ export function stubAgentWire( seal: async () => {}, restore: async () => {}, flush, + rewind: async () => {}, getModel: (model) => model.initial() as never, }; } diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e47a0f7637..61d99b09bd 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 `IAgentRewindService.rewind` 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` @@ -75,7 +75,7 @@ import { ErrorCodes, IAgentContextMemoryService, IAgentProfileService, - IAgentPromptService, + IAgentRewindService, IAgentFullCompactionService, IAgentLifecycleService, IAgentRPCService, @@ -689,12 +689,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); + // `rewind.rewind` 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(IAgentRewindService).rewind(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([ diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index de7fbf6f49..232bbc0ca1 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -204,6 +204,11 @@ export class AgentTranscriptProjector { // Known limitation: undo/clear projects as a bare 'undo' marker (raw // payload attached); no `items.remove` reconstruction in v1. return [this.markerOp('undo', restOf(event))]; + case 'context.rewound': + // Same limitation as `context.spliced`: the rewind (undo) projects as + // a bare 'undo' marker; consumers re-read via the cold/snapshot path + // (which applies `log.cut` ranges through the transcript reducer). + return [this.markerOp('undo', restOf(event))]; case 'error': return [this.noticeOp('error', event.message, restOf(event))]; case 'warning': diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index 200f7311d4..b43b2bed42 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -887,6 +887,11 @@ export class SessionEventBroadcaster { if (handle.id === MAIN_AGENT_ID && event.type === 'context.spliced') { emitLegacyStatus(); } + if (handle.id === MAIN_AGENT_ID && event.type === 'context.rewound') { + // Rewind (undo) shrinks the context like a splice; refresh the + // legacy status snapshot (token counts) for WS subscribers. + emitLegacyStatus(); + } this.onAgentEvent(sessionId, handle.id, projected); }), ]; diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 69cda99b17..33165627e8 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -614,8 +614,8 @@ 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 rewind service. + expect(res.body.stack).toEqual(expect.stringContaining('rewindService')); }); it('rejects an unsupported action suffix (40001)', async () => { From 1eb8e4b06e87aff8a866198688f8d8c9afe86101 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 23:33:20 +0800 Subject: [PATCH 2/7] refactor(agent-core-v2): keep undo domain-owned --- .../scripts/check-domain-layers.mjs | 6 +- .../contextInjector/contextInjectorService.ts | 8 - .../src/agent/contextMemory/contextMemory.ts | 3 + .../contextMemory/contextMemoryService.ts | 52 +++--- .../src/agent/contextMemory/contextOps.ts | 33 +++- .../agent/contextMemory/contextTranscript.ts | 57 +------ .../src/agent/loop/turnIndexOps.ts | 42 ----- .../agent-core-v2/src/agent/plan/planOps.ts | 61 ++++--- .../src/agent/plan/planService.ts | 17 +- .../src/agent/prompt/promptService.ts | 7 +- .../agent-core-v2/src/agent/rewind/rewind.ts | 24 +-- .../src/agent/rewind/rewindService.ts | 123 ++++----------- packages/agent-core-v2/src/agent/task/task.ts | 1 + .../src/agent/task/taskService.ts | 69 +++++--- .../src/agent/toolSelect/toolSelectService.ts | 7 - .../src/session/todo/sessionTodoService.ts | 21 +-- .../agent-core-v2/src/session/todo/todoOps.ts | 49 +++--- packages/agent-core-v2/src/wire/errors.ts | 7 - packages/agent-core-v2/src/wire/model.ts | 21 +-- packages/agent-core-v2/src/wire/op.ts | 24 +-- packages/agent-core-v2/src/wire/record.ts | 36 ----- packages/agent-core-v2/src/wire/types.ts | 6 +- packages/agent-core-v2/src/wire/wire.ts | 7 - .../agent-core-v2/src/wire/wireService.ts | 149 +----------------- .../contextMemory/contextTranscript.test.ts | 41 ----- .../test/agent/contextMemory/stubs.ts | 13 ++ .../agent/contextMemory/undoPrecheck.test.ts | 8 +- .../test/agent/plan/planOps.test.ts | 16 +- .../test/agent/rewind/rewind.test.ts | 82 +++++++++- .../test/agent/task/rpc-events.test.ts | 31 ++++ .../test/agent/task/taskService.test.ts | 1 - .../test/agent/task/tools/task-tools.test.ts | 2 + .../externalHooksRunner/integration.test.ts | 8 + packages/agent-core-v2/test/harness/agent.ts | 22 +-- packages/agent-core-v2/test/index.test.ts | 4 +- .../os/backends/node-local/tools/bash.test.ts | 2 + .../test/session/todo/sessionTodo.test.ts | 2 +- .../agent-core-v2/test/wire/rewind.test.ts | 137 ---------------- packages/agent-core-v2/test/wire/stubs.ts | 1 - .../src/services/transcript/coreEventMap.ts | 5 - .../ws/v1/sessionEventBroadcaster.ts | 5 - .../test/services/transcript.test.ts | 1 + 42 files changed, 393 insertions(+), 818 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/loop/turnIndexOps.ts delete mode 100644 packages/agent-core-v2/test/wire/rewind.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 05c4d5ffdd..8ac4aa6476 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -214,10 +214,10 @@ const DOMAIN_LAYER = new Map([ ['sessionExport', 6], ['interaction', 6], ['sessionMetadata', 6], - // `rewind` owns the undo pipeline (quiesce → log.cut → reconcile): it + // `rewind` owns the undo pipeline (quiesce → context.undo → reconcile): it // coordinates L4 agent domains (loop / prompt / contextMemory / - // fullCompaction) plus `sessionMetadata` for the `lastPrompt` reconcile, - // so it sits in L6 beside the other cross-agent coordinators. + // fullCompaction), L5 task delivery, and `sessionMetadata`, so it sits in + // L6 beside the other cross-agent coordinators. ['rewind', 6], ['sessionActivity', 6], ['session', 6], diff --git a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts index 037595198e..0c0934a166 100644 --- a/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts +++ b/packages/agent-core-v2/src/agent/contextInjector/contextInjectorService.ts @@ -56,14 +56,6 @@ export class AgentContextInjectorService extends Disposable implements IAgentCon this.handleSplice(e); }), ); - this._register( - this.eventBus.subscribe('context.rewound', () => { - // A rewind rebuilds the context wholesale; live positions are - // re-derived from the surviving history, so reminders cut by the - // rewind are re-announced on the next inject (same as post-restore). - this.resyncPositions(); - }), - ); this._register( wire.hooks.onDidRestore.register('context-injector', async (_ctx, next) => { this.resyncPositions(); diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts index f9e8ea77d7..3334d76ab4 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemory.ts @@ -1,5 +1,6 @@ import { createDecorator } from "#/_base/di/instantiation"; +import type { UndoCut } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -36,6 +37,8 @@ export interface IAgentContextMemoryService { clear(): void; + undo(count: number): UndoCut; + applyCompaction(input: ContextCompactionInput): ContextCompactionResult; } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index f66f9dea86..6d71c458d0 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -1,32 +1,19 @@ /** * `contextMemory` domain (L4) — `IAgentContextMemoryService` implementation. * - * Owns the per-agent conversation history in the wire `ContextModel` - * (`ContextMessage[]`): reads through `wire.getModel`, writes through the - * v1 wire Ops (`append` / `appendLoopEvent` / `clear` / `applyCompaction`). - * (Undo no longer flows through here: the `rewind` domain cuts the journal - * with a `log.cut` control record and the wire rebuilds this Model.) - * As the sole live mutation gateway for the history, it also cascades a - * (non-persisted) `context_size.measured` Op alongside every mutation that - * changes the measured prefix — `clear` resets it and `applyCompaction` - * adopts `tokensAfter`; `append` leaves the measured prefix untouched since - * new messages are the unmeasured tail (see `contextSizeService`). - * Splice-shaped mutations publish `context.spliced` from the live path only - * (replay rebuilds the Model silently and never invokes these methods), so - * existing subscribers observe the same change regardless of which Op was - * persisted. Messages - * are persisted without local ids — the on-disk record matches v1's field set - * and public message ids are derived from the transcript index. Blob - * dehydrate/rehydrate is declared on `ContextModel.blobs`. Bound at + * Owns per-agent conversation history through `wire`, maintains measurements + * with `contextSize`, and broadcasts live mutations through `event`. Bound at * Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { estimateTokensForMessages } from '#/kosong/contract/tokens'; import { IEventBus } from '#/app/event/eventBus'; -import { contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; +import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; import { IWireService } from '#/wire/wire'; +import type { Op } from '#/wire/op'; import { IAgentContextMemoryService, @@ -35,11 +22,15 @@ import { } from './contextMemory'; import { buildContextCompactionShape } from './compactionHandoff'; import { + computeUndoCut, ContextModel, contextAppendLoopEvent, contextAppendMessage, contextApplyCompaction, contextClear, + contextUndo, + isFullyUndoable, + type UndoCut, } from './contextOps'; import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; @@ -87,6 +78,20 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte this.publishSplice({ start: 0, deleteCount, messages: [] }); } + undo(count: number): UndoCut { + const history = this.get(); + const cut = computeUndoCut(history, count); + if (isFullyUndoable(cut, count)) { + this.wire.dispatch(contextUndo({ count }), ...this.sizeOpsForCut(cut.cutIndex, history)); + this.publishSplice({ + start: cut.cutIndex, + deleteCount: history.length - cut.cutIndex, + messages: [], + }); + } + return cut; + } + applyCompaction(input: ContextCompactionInput): ContextCompactionResult { const history = this.get(); const result = buildContextCompactionShape(history, input); @@ -122,6 +127,17 @@ export class AgentContextMemoryService extends Disposable implements IAgentConte }): void { this.eventBus.publish({ type: 'context.spliced', ...input }); } + + private sizeOpsForCut(cutIndex: number, history: readonly ContextMessage[]): Op[] { + const model = this.wire.getModel(ContextSizeModel); + if (model.length <= cutIndex) return []; + return [ + contextSizeMeasured({ + length: cutIndex, + tokens: estimateTokensForMessages(history.slice(0, cutIndex)), + }), + ]; + } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 1aefe5fc93..8446efadd0 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -101,7 +101,6 @@ async function dehydrateRecord( } export const ContextModel = defineModel('contextMemory', () => [], { - rewindable: true, blobs: { dehydrate: dehydrateRecord, rehydrate: async (state, transform) => { @@ -320,18 +319,36 @@ export function isFullyUndoable(cut: UndoCut, count: number): boolean { export type UndoUnavailableReason = 'empty' | 'compaction_boundary' | 'insufficient'; -export function formatUndoUnavailableMessage(input: { - readonly reason: UndoUnavailableReason; - readonly requested: number; - readonly undoable: number; -}): string { - switch (input.reason) { +export type UndoPrecheck = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: UndoUnavailableReason; + readonly requested: number; + readonly undoable: number; + }; + +export function precheckUndo(history: readonly ContextMessage[], count: number): UndoPrecheck { + const cut = computeUndoCut(history, count); + if (isFullyUndoable(cut, count)) return { ok: true }; + const reason: UndoUnavailableReason = cut.stoppedAtCompaction + ? 'compaction_boundary' + : cut.removedCount === 0 + ? 'empty' + : 'insufficient'; + return { ok: false, reason, requested: count, undoable: cut.removedCount }; +} + +export function formatUndoUnavailableMessage( + precheck: Extract, +): string { + switch (precheck.reason) { case 'empty': return 'Nothing to undo: no user message to undo'; case 'compaction_boundary': return 'Nothing to undo: would cross a compaction boundary'; case 'insufficient': - return `Nothing to undo: only ${input.undoable} of ${input.requested} requested turn(s) available`; + return `Nothing to undo: only ${precheck.undoable} of ${precheck.requested} requested turn(s) available`; } } diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index fc5f2c2d34..c719356be6 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -31,7 +31,7 @@ */ import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; -import { isLogCutRecord, type WireRecord } from '#/wire/record'; +import type { WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, @@ -73,63 +73,12 @@ interface MutableEntry { } export function reduceContextTranscript(records: Iterable): ContextTranscript { - // Two passes over the buffered input: `log.cut` control records invalidate - // the record range [target, cut position) (wire rewind semantics), so the - // effective record stream is only known once the cuts have been seen. - const buffered = [...records]; - const reducer = createRawContextTranscriptReducer(); - for (const record of effectiveWireRecords(buffered)) reducer.add(record); + const reducer = createContextTranscriptReducer(); + for (const record of records) reducer.add(record); return reducer.result(); } export function createContextTranscriptReducer(): ContextTranscriptReducer { - // Streaming callers (`reducer.add` per record, one final `result()`) - // buffer transparently so `log.cut` ranges apply exactly like the - // one-shot `reduceContextTranscript` path. - const buffered: WireRecord[] = []; - return { - add(record: WireRecord): void { - buffered.push(record); - }, - result(): ContextTranscript { - return reduceContextTranscript(buffered); - }, - }; -} - -/** - * The effective record stream after wire rewinds: every record whose - * (metadata-free) index falls outside every `log.cut` range - * `[target, cutPosition)`. Cut records themselves pass through (reducers - * ignore unknown types); malformed cuts consume their index but no range, - * matching the wire restore. - */ -function effectiveWireRecords(records: readonly WireRecord[]): Iterable { - const ranges: Array = []; - let index = 0; - for (const record of records) { - if (record.type === 'metadata') continue; - if (isLogCutRecord(record)) { - ranges.push([Math.min(record.target, index), index]); - } - index++; - } - if (ranges.length === 0) return records; - let outputIndex = 0; - const out: WireRecord[] = []; - for (const record of records) { - if (record.type === 'metadata') { - out.push(record); - continue; - } - const i = outputIndex++; - const invalid = ranges.some(([start, end]) => i >= start && i < end); - if (!invalid) out.push(record); - } - return out; -} - -function createRawContextTranscriptReducer(): ContextTranscriptReducer { const transcript: MutableEntry[] = []; let foldedLength = 0; let clearFloor = 0; diff --git a/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts b/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts deleted file mode 100644 index dabba1ee18..0000000000 --- a/packages/agent-core-v2/src/agent/loop/turnIndexOps.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * `loop` domain (L4) — wire Model (`TurnIndexModel`) indexing user-turn - * boundaries and compaction points by journal position. - * - * Declares the rewind coordinate system for `IAgentRewindService`: the journal - * line index of every user-driven `turn.prompt` record (`turnStarts`) and of - * the most recent `context.apply_compaction` record (`lastCompactionIndex`, - * `-1` when none). "User-driven" reuses the single classification - * implementation (`compactionUserMessageDisposition`) — applied here, on the - * authoritative submission record, rather than by scanning history messages — - * so cron/goal/hook-initiated turns never become undo anchors. Both fields are - * derived through cross-model reducers fed with `OpApplyContext.recordIndex`, - * so the index requires no scanning at rewind time — and because the model is - * `rewindable`, a `log.cut` rebuild re-derives it from exactly the surviving - * records, keeping successive rewinds consistent. Consumed by the Agent-scope - * `rewindService`; not a user-facing surface. - */ - -import { compactionUserMessageDisposition } from '#/agent/contextMemory/compactionHandoff'; -import { defineModel } from '#/wire/model'; - -export interface TurnIndexState { - readonly turnStarts: readonly number[]; - readonly lastCompactionIndex: number; -} - -export const TurnIndexModel = defineModel( - 'turnIndex', - () => ({ turnStarts: [], lastCompactionIndex: -1 }), - { - rewindable: true, - reducers: { - 'turn.prompt': (state, payload, ctx) => - ctx?.recordIndex === undefined || - compactionUserMessageDisposition(payload.origin) !== 'keep' - ? state - : { ...state, turnStarts: [...state.turnStarts, ctx.recordIndex] }, - 'context.apply_compaction': (state, _payload, ctx) => - ctx?.recordIndex === undefined ? state : { ...state, lastCompactionIndex: ctx.recordIndex }, - }, - }, -); diff --git a/packages/agent-core-v2/src/agent/plan/planOps.ts b/packages/agent-core-v2/src/agent/plan/planOps.ts index f8dd495217..44407910b3 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -1,26 +1,13 @@ /** - * `plan` domain (L4) — wire Model (`PlanModel`) and the `plan_mode.enter` - * (`planModeEnter`) / `plan_mode.cancel` (`planModeCancel`) / `plan_mode.exit` - * (`planModeExit`) Ops that mirror the plan-mode lifecycle into a persisted, - * replayable `{ active, id }` state. + * `plan` domain (L4) — replayable plan-mode wire state. * - * The Model holds the persistent, replayable fields — whether plan mode is - * active and the plan id. The persisted records carry 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`. - * Each `apply` returns the same reference on a no-op (re-entering the same - * plan, or cancelling/exiting while already inactive) so the wire's - * reference-equality gate stays quiet. The side effects — `telemetryContext` - * mode, plan-directory/file fs I/O, and the `agent.status.updated` planMode - * slice — are NOT part of `apply`: they run after `wire.dispatch` on the live - * path, and `wire.replay` rebuilds the Model silently from the persisted - * `plan_mode.*` records (seeded by `sessionLifecycle`). The legacy - * `toReplay: plan_updated` projection is dropped (inert — nothing reads it). - * Consumed by the Agent-scope `planService`. + * Owns the persisted plan lifecycle state consumed by the Agent-scoped + * `planService` and keeps it consistent with conversation undo. */ import { z } from 'zod'; +import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { defineModel } from '#/wire/model'; export interface PlanState { @@ -28,13 +15,41 @@ export interface PlanState { readonly id?: string; } -export const PlanModel = defineModel('plan', () => ({ active: false }), { - rewindable: true, +export interface PlanModelState { + readonly current: PlanState; + readonly checkpoints: readonly PlanState[]; +} + +export const PlanModel = defineModel('plan', () => ({ + current: { active: false }, + checkpoints: [], +}), { + reducers: { + 'context.append_message': (state, { message }) => + isRealUserInput(message) + ? { ...state, checkpoints: [...state.checkpoints, state.current] } + : state, + '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 (count <= 0 || state.checkpoints.length < count) return state; + const checkpointIndex = state.checkpoints.length - count; + return { + current: state.checkpoints[checkpointIndex]!, + checkpoints: state.checkpoints.slice(0, checkpointIndex), + }; + }, + }, }); export const planModeEnter = PlanModel.defineOp('plan_mode.enter', { schema: z.object({ id: z.string() }), - apply: (s, p) => (s.active && s.id === p.id ? s : { active: true, id: p.id }), + apply: (s, p) => + s.current.active && s.current.id === p.id + ? s + : { ...s, current: { active: true, id: p.id } }, toEvent: () => ({ type: 'agent.status.updated' as const, planMode: true }), }); @@ -48,12 +63,14 @@ 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 }), + apply: (s) => + s.current.active ? { ...s, current: { active: false } } : 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 }), + apply: (s) => + s.current.active ? { ...s, current: { active: false } } : s, toEvent: () => ({ type: 'agent.status.updated' as const, planMode: false }), }); diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 9c63cec358..02971460cb 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -18,6 +18,7 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { PlanModeInjection } from '#/agent/plan/injection/planModeInjection'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; @@ -42,6 +43,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { @IHostFileSystem private readonly hostFs: IHostFileSystem, @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, @@ -54,24 +56,27 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { await next(); }), ); + this._register( + eventBus.subscribe('context.rewound', () => { + this.restoreTelemetryMode(); + }), + ); this._register(new PlanModeInjection(dynamicInjector, this, this.context)); } 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 { @@ -118,7 +123,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/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index 3902e1e6e3..f224bcee08 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -18,7 +18,7 @@ import { newMessageId } from '#/agent/contextMemory/messageId'; 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'; -import { promptTurn, steerTurn } from '#/agent/loop/turnOps'; +import { steerTurn } from '#/agent/loop/turnOps'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import type { ExecutableToolResult } from '#/tool/toolContract'; import type { ToolDidExecuteContext } from '#/agent/toolExecutor/toolHooks'; @@ -183,11 +183,6 @@ export class AgentPromptService implements IAgentPromptService { if (this.fullCompaction.compacting !== null && this.loop.status().state !== 'running') { this.pending.unshift(item); return; } const { message, captions } = this.extractCompressionCaptions(item.message); if (await this.blockedByHook(message, false)) { - // A hook-blocked prompt never starts a turn, but it IS a user prompt - // boundary in the journal — record `turn.prompt` so the rewind index - // sees it as an undo anchor (and `TurnModel` stays a faithful count - // of submitted prompts). - this.wire.dispatch(promptTurn({ input: message.content, origin: message.origin ?? USER_PROMPT_ORIGIN })); this.appendPrompt(message, captions); item.state = 'blocked'; item.launchedDeferred.resolve(undefined); item.completionDeferred.resolve({ promptId: item.id, result: undefined, state: 'blocked' }); this.publishCompleted(item.id, 'blocked'); return; diff --git a/packages/agent-core-v2/src/agent/rewind/rewind.ts b/packages/agent-core-v2/src/agent/rewind/rewind.ts index 84d36041a7..b34fa4a5e1 100644 --- a/packages/agent-core-v2/src/agent/rewind/rewind.ts +++ b/packages/agent-core-v2/src/agent/rewind/rewind.ts @@ -1,39 +1,21 @@ /** - * `rewind` domain (L6) — the single owner of the undo operation. + * `rewind` domain (L6) — Agent-scoped conversation undo contract. * - * `IAgentRewindService` turns "undo N user turns" into the rewind pipeline: - * quiesce (abort the active turn, cancel in-flight compaction, pause prompt - * launching) → precheck (turn boundaries from `TurnIndexModel`, compaction - * boundary) → `wire.rewind` (persist a `log.cut` control record and rebuild - * every rewindable model) → reconcile (context-size rebase, `lastPrompt`) → - * telemetry + `context.rewound`. Every entry point (REST `:undo`, RPC - * `undoHistory`, debug surface) converges here so the operation has exactly - * one guard, one error contract, and one telemetry call. + * Defines the availability and execution surface shared by every undo entry + * point. Bound at Agent scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; export interface RewindAvailability { - /** How many trailing user turns a rewind may cut (compaction-boundary aware). */ readonly maxTurns: number; - /** Whether an uncompacted compaction record bounds the rewindable range. */ readonly stoppedAtCompaction: boolean; } export interface IAgentRewindService { readonly _serviceBrand: undefined; - /** Current rewind availability — the server-side source for undo selectors. */ availability(): RewindAvailability; - - /** - * Undo the last `turns` user turns of this agent. Aborts an active turn and - * cancels an in-flight compaction first (quiesce), then rewinds every - * rewindable model to the Nth-to-last `turn.prompt` record. Pending prompt - * queue is preserved. Throws `session.undo_unavailable` (with a structured - * `reason`) when fewer than `turns` turns may be cut. Resolves to the number - * of turns actually cut. - */ rewind(turns: number): Promise; } diff --git a/packages/agent-core-v2/src/agent/rewind/rewindService.ts b/packages/agent-core-v2/src/agent/rewind/rewindService.ts index d8d5b090cb..2700907e1b 100644 --- a/packages/agent-core-v2/src/agent/rewind/rewindService.ts +++ b/packages/agent-core-v2/src/agent/rewind/rewindService.ts @@ -1,48 +1,37 @@ /** * `rewind` domain (L6) — `IAgentRewindService` implementation. * - * Pipeline (see `rewind.ts` for the contract): quiesce → precheck → - * `wire.rewind` → reconcile → telemetry/event. Quiesce pauses prompt - * launching (pending queue preserved), aborts the active turn and waits for - * the loop to settle, then cancels an in-flight compaction — so no producer - * can append records between the precheck and the cut, and no in-flight - * reader (a running turn's materialized context, a compaction's snapshot) - * can write pre-rewind results onto post-rewind state. Bound at Agent scope. + * Coordinates `prompt`, `loop`, and `fullCompaction`; persists history changes + * through `contextMemory`; reconciles `task` delivery and `sessionMetadata`; + * and reports through `telemetry` and `event`. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { estimateTokensForMessages } from '#/kosong/contract/tokens'; +import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { + computeUndoCut, formatUndoUnavailableMessage, - type UndoUnavailableReason, + precheckUndo, } from '#/agent/contextMemory/contextOps'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService } from '#/agent/loop/loop'; -import { TurnIndexModel } from '#/agent/loop/turnIndexOps'; import { IAgentPromptService } from '#/agent/prompt/prompt'; +import { IAgentTaskService } from '#/agent/task/task'; import { IEventService } from '#/app/event/event'; import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2 } from '#/errors'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; -import { IWireService } from '#/wire/wire'; import { IAgentRewindService, type RewindAvailability } from './rewind'; declare module '#/app/event/eventBus' { interface DomainEventMap { - /** - * Published after a rewind cut has been applied live. Subscribers with - * history-derived bookkeeping (injection positions, pending tool loads, - * task-notification delivery mirrors) re-derive from the rebuilt models. - */ - 'context.rewound': { target: number; turns: number }; + 'context.rewound': { turns: number }; } } @@ -50,11 +39,11 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic declare readonly _serviceBrand: undefined; constructor( - @IWireService private readonly wire: IWireService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, @IAgentPromptService private readonly prompt: IAgentPromptService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, + @IAgentTaskService private readonly tasks: IAgentTaskService, @ISessionContext private readonly session: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, @IEventService private readonly eventService: IEventService, @@ -65,96 +54,50 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic } availability(): RewindAvailability { - const { turnStarts, lastCompactionIndex } = this.wire.getModel(TurnIndexModel); - let maxTurns = 0; - for (const start of turnStarts) { - if (start > lastCompactionIndex) maxTurns++; - } - return { maxTurns, stoppedAtCompaction: lastCompactionIndex >= 0 }; + const cut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); + return { maxTurns: cut.removedCount, stoppedAtCompaction: cut.stoppedAtCompaction }; } async rewind(turns: number): Promise { if (turns <= 0) return 0; const pause = this.prompt.pauseLaunching(); try { - // Quiesce: abort the active turn (its abort propagates into a blocking - // compaction wait) and wait for the loop to drain, then cancel any - // remaining in-flight compaction. No records are appended afterwards - // until the cut lands. if (this.loop.status().state === 'running') { this.loop.cancel(this.loop.status().activeTurnId); } await this.loop.settled(); await this.fullCompaction.cancel(); - const target = this.precheckTarget(turns); - await this.wire.rewind(target, 'undo'); - this.rebaseMeasuredTokens(); - await this.reconcileLastPrompt(); + 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, + }, + }, + ); + } + this.context.undo(turns); + await this.tasks.reconcileNotificationDeliveryAfterUndo().catch(() => {}); + await this.reconcileLastPrompt().catch(() => {}); this.telemetry.track2('conversation_undo', { count: turns }); - this.eventBus.publish({ type: 'context.rewound', target, turns }); + this.eventBus.publish({ type: 'context.rewound', turns }); return turns; } finally { pause.dispose(); } } - /** - * The cut point is the journal line of the Nth-to-last `turn.prompt` - * record, restricted to turns after the most recent compaction (the - * product-level compaction boundary; the wire protocol itself could cut - * across it). Throws `session.undo_unavailable` when unsatisfiable. - */ - private precheckTarget(turns: number): number { - const { turnStarts, lastCompactionIndex } = this.wire.getModel(TurnIndexModel); - const eligible: number[] = []; - for (const start of turnStarts) { - if (start > lastCompactionIndex) eligible.push(start); - } - if (eligible.length >= turns) { - return eligible[eligible.length - turns]!; - } - const reason: UndoUnavailableReason = - eligible.length === 0 - ? lastCompactionIndex >= 0 - ? 'compaction_boundary' - : 'empty' - : lastCompactionIndex >= 0 - ? 'compaction_boundary' - : 'insufficient'; - throw new Error2( - ErrorCodes.SESSION_UNDO_UNAVAILABLE, - formatUndoUnavailableMessage({ reason, requested: turns, undoable: eligible.length }), - { details: { reason, requestedCount: turns, undoableCount: eligible.length } }, - ); - } - - /** - * The measured token prefix is a live-only (transient) model, so the rewind - * cannot rebuild it; when the cut truncates the measured prefix, rebase it - * to an estimate over the surviving history (the pre-rewind `undo` - * behavior, centralized here). - */ - private rebaseMeasuredTokens(): void { - const surviving = this.context.get(); - const measured = this.wire.getModel(ContextSizeModel); - if (measured.length <= surviving.length) return; - this.wire.dispatch( - contextSizeMeasured({ - length: surviving.length, - tokens: estimateTokensForMessages(surviving), - }), - ); - } - - /** Best-effort `lastPrompt` reconcile: adopt the last surviving real user - * prompt; leave the field untouched when none survives. Titles are never - * rewritten here (custom or auto). */ private async reconcileLastPrompt(): Promise { const history = this.context.get(); for (let i = history.length - 1; i >= 0; i--) { const message = history[i]!; - if (!isUserPromptMessage(message)) continue; + if (!isRealUserInput(message)) continue; const text = message.content .filter((part) => part.type === 'text') .map((part) => ('text' in part ? part.text : '')) @@ -173,12 +116,6 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic } } -function isUserPromptMessage(message: ContextMessage): boolean { - if (message.role !== 'user') return false; - const origin = message.origin; - return origin === undefined || origin.kind === 'user'; -} - registerScopedService( LifecycleScope.Agent, IAgentRewindService, diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 9e091502fa..5d430da471 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -83,6 +83,7 @@ export interface IAgentTaskService { registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; getTask(taskId: string): AgentTaskInfo | undefined; list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; + reconcileNotificationDeliveryAfterUndo(): Promise; persistOutput(taskId: string): void; getOutputSnapshot( taskId: string, diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 786bc52da3..ff54ff2b32 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -42,6 +42,7 @@ import { } from '#/_base/utils/abort'; import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; +import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -107,19 +108,38 @@ interface AgentTaskNotificationBuildContext { readonly notification: AgentTaskNotification; } -const TaskNotificationDeliveryModel = defineModel( +interface TaskNotificationDeliveryState { + readonly current: readonly string[]; + readonly checkpoints: readonly (readonly string[])[]; +} + +const TaskNotificationDeliveryModel = defineModel( 'task.notificationDelivery', - () => [], + () => ({ current: [], checkpoints: [] }), { - // Rewindable: a rewind drops the delivery marks of notifications the cut - // removed from the context, so they re-deliver on the next inject. - rewindable: true, reducers: { - 'context.append_message': (state, payload: { message?: unknown }) => { - const origin = taskOriginFromMessage(payload.message); + 'context.append_message': (state, { message }) => { + if (isRealUserInput(message)) { + return { ...state, checkpoints: [...state.checkpoints, state.current] }; + } + const origin = taskOriginFromMessage(message); if (origin === undefined) return state; const key = notificationKey(origin); - return state.includes(key) ? state : [...state, key]; + return state.current.includes(key) + ? state + : { ...state, current: [...state.current, key] }; + }, + '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 (count <= 0 || state.checkpoints.length < count) return state; + const checkpointIndex = state.checkpoints.length - count; + return { + current: state.checkpoints[checkpointIndex]!, + checkpoints: state.checkpoints.slice(0, checkpointIndex), + }; }, }, }, @@ -220,6 +240,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); private readonly persistence: AgentTaskPersistence; + private notificationRestoreQueue: Promise = Promise.resolve(); private activeTaskReminderPending = false; constructor( @@ -250,7 +271,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { ); 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(); @@ -269,16 +290,6 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } }), ); - this._register( - this.eventBus.subscribe('context.rewound', () => { - // The delivery model was rebuilt by the rewind; re-sync the live - // mirror so notifications cut from the context re-deliver. - this.deliveredNotificationKeys.clear(); - for (const key of this.wire.getModel(TaskNotificationDeliveryModel)) { - this.deliveredNotificationKeys.add(key); - } - }), - ); this._register( injector.register(ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT, () => this.activeBackgroundTaskReminder(), @@ -483,6 +494,16 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return result; } + async reconcileNotificationDeliveryAfterUndo(): Promise { + const restoredKeys = new Set(this.wire.getModel(TaskNotificationDeliveryModel).current); + for (const key of this.deliveredNotificationKeys) { + if (!restoredKeys.has(key)) this.scheduledNotificationKeys.delete(key); + } + this.deliveredNotificationKeys.clear(); + for (const key of restoredKeys) this.deliveredNotificationKeys.add(key); + await this.restoreAgentTaskNotifications(); + } + persistOutput(taskId: string): void { const entry = this.tasks.get(taskId); if (entry === undefined) return; @@ -1024,7 +1045,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { this.fireNotificationHook(context.notification); } - 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); diff --git a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts index 6b5dd9b867..e798e9eb8f 100644 --- a/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts +++ b/packages/agent-core-v2/src/agent/toolSelect/toolSelectService.ts @@ -69,13 +69,6 @@ export class AgentToolSelectService extends Disposable implements IAgentToolSele this.dropPendingLoadedNotLanded(); }), ); - this._register( - eventBus.subscribe('context.rewound', () => { - // Same re-derivation as the splice path: pending loaded tools whose - // landing messages the rewind removed must not stick. - this.dropPendingLoadedNotLanded(); - }), - ); } private dropPendingLoadedNotLanded(): void { diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index a7dcd94fb4..67801bbac0 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -1,20 +1,9 @@ /** * `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 - * 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. + * Provides session-wide todo access through the main agent's `wire`, binds + * todo capabilities into each agent, and publishes changes through its typed + * event. Bound at Session scope. */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; @@ -73,7 +62,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 { @@ -93,7 +82,7 @@ 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)); + this.onDidChangeEmitter.fire(wire.getModel(TodoModel).current); } private bindAgent(handle: IAgentScopeHandle): void { diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 7016781b94..651511b3d2 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -1,33 +1,41 @@ /** - * `todo` domain (L4) — wire Model (`TodoModel`) and the `tools.update_store` - * Op (`todoSet`) for the session's shared todo list. + * `todo` domain (L4) — replayable wire state for the shared todo list. * - * 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. + * Owns validated todo state consumed by the Session-scoped + * `SessionTodoService` and keeps it consistent with conversation undo. */ import { z } from 'zod'; +import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; import { defineModel } from '#/wire/model'; import { readTodoItems, type TodoItem } from './todoItem'; -export type TodoModelState = readonly TodoItem[]; +export interface TodoModelState { + readonly current: readonly TodoItem[]; + readonly checkpoints: readonly (readonly TodoItem[])[]; +} -export const TodoModel = defineModel('todo', () => [], { - // Rewindable: todos written by an undone turn disappear with it. - rewindable: true, +export const TodoModel = defineModel('todo', () => ({ current: [], checkpoints: [] }), { + reducers: { + 'context.append_message': (state, { message }) => + isRealUserInput(message) + ? { ...state, checkpoints: [...state.checkpoints, state.current] } + : state, + '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 (count <= 0 || state.checkpoints.length < count) return state; + const checkpointIndex = state.checkpoints.length - count; + return { + current: state.checkpoints[checkpointIndex]!, + checkpoints: state.checkpoints.slice(0, checkpointIndex), + }; + }, + }, }); declare module '#/wire/types' { @@ -38,5 +46,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/src/wire/errors.ts b/packages/agent-core-v2/src/wire/errors.ts index 8b77822990..30e934b06f 100644 --- a/packages/agent-core-v2/src/wire/errors.ts +++ b/packages/agent-core-v2/src/wire/errors.ts @@ -17,7 +17,6 @@ export const WireErrors = { WIRE_DUPLICATE_OP: 'wire.duplicate_op', WIRE_CYCLE: 'wire.cycle', WIRE_UNKNOWN_RECORD: 'wire.unknown_record', - WIRE_INVALID_REWIND_TARGET: 'wire.invalid_rewind_target', RECORDS_WRITE_FAILED: 'records.write_failed', }, info: { @@ -39,12 +38,6 @@ export const WireErrors = { public: true, action: 'The record was written by a newer version; upgrade or drop it.', }, - 'wire.invalid_rewind_target': { - title: 'Invalid wire rewind target', - retryable: false, - public: true, - action: 'The log.cut target is outside the journal bounds; recompute it from the current journal length.', - }, 'records.write_failed': { title: 'Wire journal write failed', retryable: false, diff --git a/packages/agent-core-v2/src/wire/model.ts b/packages/agent-core-v2/src/wire/model.ts index 04cac70106..cf22f3ef35 100644 --- a/packages/agent-core-v2/src/wire/model.ts +++ b/packages/agent-core-v2/src/wire/model.ts @@ -39,7 +39,7 @@ * applied by `WireService` after every `apply`. Scope-agnostic. */ -import { bindDefineOp, type DefineOpFn, type OpApplyContext } from '#/wire/op'; +import { bindDefineOp, type DefineOpFn } from '#/wire/op'; import type { ModelReducers } from '#/wire/types'; import type { WireRecord } from '#/wire/record'; @@ -54,27 +54,14 @@ export interface ModelDef { readonly name: string; readonly initial: () => S; readonly blobs?: ModelBlobCodec; - /** - * Temporal classification of the model's state: `true` marks state that - * belongs to conversation time and is rebuilt by `log.cut` rewinds - * (`IWireService.rewind`); absent/`false` marks world-time state (usage, - * task registries, turn counters) that survives rewinds untouched. - */ - readonly rewindable?: boolean; readonly defineOp: DefineOpFn; } export interface ModelCrossReducerEntry { // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly model: ModelDef; - readonly reducer: ( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - state: any, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - payload: any, - ctx?: OpApplyContext, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ) => any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + readonly reducer: (state: any, payload: any) => any; } export const MODEL_CROSS_REDUCERS = new Map(); @@ -85,14 +72,12 @@ export function defineModel( opts?: { blobs?: ModelBlobCodec; reducers?: ModelReducers; - rewindable?: boolean; }, ): ModelDef { const def: ModelDef = { name, initial, blobs: opts?.blobs, - rewindable: opts?.rewindable, defineOp: bindDefineOp(() => def), }; if (opts?.reducers !== undefined) { diff --git a/packages/agent-core-v2/src/wire/op.ts b/packages/agent-core-v2/src/wire/op.ts index 8edfa9979d..d635ddb5d0 100644 --- a/packages/agent-core-v2/src/wire/op.ts +++ b/packages/agent-core-v2/src/wire/op.ts @@ -42,25 +42,11 @@ export class DuplicateOpError extends WireError { } } -/** - * Context handed to `apply` / cross-model reducers alongside the payload. - * `recordIndex` is the Op's own position in the journal (0-based over - * NON-metadata records): assigned by `WireService` on live dispatch for - * persisted Ops, and supplied from the replay cursor during restore. It is - * `undefined` for transient (`persist: false`) Ops on the live path — they - * never occupy a journal line. Ops that only transform state ignore it; Ops - * that index journal positions (e.g. turn-boundary indexes feeding `log.cut` - * targets) require it. - */ -export interface OpApplyContext { - readonly recordIndex?: number; -} - export interface OpDescriptor { readonly type: K; readonly model: ModelDef; readonly schema: z.ZodType

; - readonly apply: (state: S, payload: P, ctx?: OpApplyContext) => S; + readonly apply: (state: S, payload: P) => S; readonly toEvent?: (payload: P, state: S) => unknown; readonly persist?: boolean; } @@ -68,12 +54,6 @@ export interface OpDescriptor { export interface Op { readonly type: K; readonly payload: P; - /** - * Journal position of this Op's record, set only by `WireService` internals - * (restore replay and, for persisted Ops, live dispatch). Never set by - * callers of `dispatch`. - */ - readonly recordIndex?: number; // eslint-disable-next-line @typescript-eslint/no-explicit-any readonly descriptor: OpDescriptor; } @@ -83,7 +63,7 @@ export const OP_REGISTRY = new Map>(); interface OpBehaviorOptions { readonly schema: z.ZodType

; - readonly apply: (state: S, payload: P, ctx?: OpApplyContext) => S; + readonly apply: (state: S, payload: P) => S; readonly toEvent?: (payload: P, state: S) => unknown; } diff --git a/packages/agent-core-v2/src/wire/record.ts b/packages/agent-core-v2/src/wire/record.ts index d65cdc5ef0..92a9bc28f9 100644 --- a/packages/agent-core-v2/src/wire/record.ts +++ b/packages/agent-core-v2/src/wire/record.ts @@ -25,42 +25,6 @@ export interface WireMetadataRecord extends WireRecord { readonly created_at: number; } -/** - * `log.cut` — the wire layer's rewind control record. NOT an Op: it never - * enters `OP_REGISTRY` or any business model. `WireService` interprets it - * during restore and writes it from `rewind()`. `target` is a record index - * (0-based over NON-metadata records — the metadata envelope never occupies - * an index, so migration rewrites that add it never shift targets): on - * encountering the record, every `rewindable` model is reset to the fold of - * records `[0, target)`, and replay then continues after this record. - * `reason` is audit metadata (e.g. 'undo'). - */ -export const LOG_CUT_RECORD_TYPE = 'log.cut'; - -export interface LogCutRecord extends WireRecord { - readonly type: typeof LOG_CUT_RECORD_TYPE; - readonly target: number; - readonly reason?: string; -} - -export function isLogCutRecord(record: WireRecord): record is LogCutRecord { - return ( - record.type === LOG_CUT_RECORD_TYPE && - typeof record['target'] === 'number' && - Number.isInteger(record['target']) && - record['target'] >= 0 - ); -} - -export function createLogCutRecord(target: number, reason?: string, now = Date.now()): LogCutRecord { - return { - type: LOG_CUT_RECORD_TYPE, - target, - ...(reason !== undefined ? { reason } : {}), - time: now, - }; -} - export function isWireRecord(record: unknown): record is WireRecord { return ( record !== null && diff --git a/packages/agent-core-v2/src/wire/types.ts b/packages/agent-core-v2/src/wire/types.ts index 66a0f4a197..f084c312b0 100644 --- a/packages/agent-core-v2/src/wire/types.ts +++ b/packages/agent-core-v2/src/wire/types.ts @@ -34,11 +34,7 @@ export type OpPayload = K extends PersistedOpType : never; export type ModelReducers = { - [K in OpType]?: ( - state: S, - payload: OpPayload, - ctx?: { readonly recordIndex?: number }, - ) => S; + [K in OpType]?: (state: S, payload: OpPayload) => S; }; export type OpPersistenceOptions = K extends PersistedOpType diff --git a/packages/agent-core-v2/src/wire/wire.ts b/packages/agent-core-v2/src/wire/wire.ts index 3d05619008..86e36fd66c 100644 --- a/packages/agent-core-v2/src/wire/wire.ts +++ b/packages/agent-core-v2/src/wire/wire.ts @@ -28,13 +28,6 @@ export interface IWireService { seal(): Promise; restore(): Promise; flush(): Promise; - /** - * Rewind every `rewindable` model to the fold of journal records - * `[0, target)` and append a `log.cut` control record. `target` is a - * journal line index (0-based, metadata line included). Callers must - * quiesce record producers (loop, compaction) before rewinding. - */ - rewind(target: number, reason?: string): Promise; getModel(model: ModelDef): DeepReadonly; } diff --git a/packages/agent-core-v2/src/wire/wireService.ts b/packages/agent-core-v2/src/wire/wireService.ts index 2629a27095..e2eec95dce 100644 --- a/packages/agent-core-v2/src/wire/wireService.ts +++ b/packages/agent-core-v2/src/wire/wireService.ts @@ -36,13 +36,11 @@ import { } from './migration/migration'; import type { DeepReadonly, ModelDef, PartsTransformer } from './model'; import { MODEL_CROSS_REDUCERS } from './model'; -import type { Op, OpApplyContext } from './op'; +import type { Op } from './op'; import { OP_REGISTRY } from './op'; import { AGENT_WIRE_RECORD_KEY, - createLogCutRecord, createWireMetadataRecord, - isLogCutRecord, isWireRecord, isWireMetadataRecord, opToWireRecord, @@ -86,18 +84,9 @@ export class WireService extends Disposable implements IWireService { private restorePhase: RestorePhase = 'new'; private dispatching = false; - private rewinding = false; private queue: Op[] = []; private drainDepth = 0; private persistQueue: Promise | undefined; - /** - * Cursor over NON-metadata journal records (0-based): the index the next - * appended data record will occupy in the metadata-free record coordinate - * system shared by `log.cut` targets and `OpApplyContext.recordIndex`. - * The metadata envelope line never consumes an index, so adding or - * removing it (seal / migration rewrite) never shifts record positions. - */ - private nextRecordIndex = 0; constructor( @IAgentScopeContext scopeContext: IAgentScopeContext, @@ -116,9 +105,6 @@ export class WireService extends Disposable implements IWireService { dispatch(...ops: Op[]): void { if (ops.length === 0) return; - if (this.rewinding) { - throw new BugIndicatingError('Wire dispatch during an in-flight rewind is not allowed'); - } if (this.dispatching) { this.queue.push(...ops); return; @@ -161,8 +147,6 @@ export class WireService extends Disposable implements IWireService { let migrations: readonly WireMigration[] = []; let rewrittenRecords: WireRecord[] | undefined; let newerWireVersion = false; - // Cursor over NON-metadata records — the metadata-free coordinate - // system `log.cut` targets and `OpApplyContext.recordIndex` share. let recordIndex = 0; let hasRecords = false; @@ -201,17 +185,6 @@ export class WireService extends Disposable implements IWireService { : migratedRecord; rewrittenRecords?.push(record); if (record.type === 'metadata') continue; - if (record.type === 'log.cut') { - // Wire-layer control record: rewind every rewindable model to the - // fold of records [0, target), then continue replay after it. - if (isLogCutRecord(record)) { - await this.rebuildRewindableModels(Math.min(record.target, recordIndex), migrations); - } else { - this.reportSkippedRecord(record.type, recordIndex, true); - } - recordIndex++; - continue; - } this.replayRecord(record, recordIndex); recordIndex++; @@ -220,7 +193,6 @@ export class WireService extends Disposable implements IWireService { if (!hasRecords) { rewrittenRecords = [createWireMetadataRecord()]; } - this.nextRecordIndex = recordIndex; if (rewrittenRecords !== undefined) { await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords); } @@ -239,111 +211,6 @@ export class WireService extends Disposable implements IWireService { await this.log.flush(); } - /** - * Rewind every `rewindable` model to the fold of journal records - * `[0, target)`, then append a `log.cut` control record marking the rewind. - * Non-rewindable (world-time) models keep their current state. Live and - * restore share the same rebuild path (`rebuildRewindableModels`), so the - * post-rewind state is by construction identical to a fresh replay of the - * journal up to the cut. Dispatches must not interleave with a rewind — - * callers quiesce producers (loop, compaction) first; any dispatch arriving - * while a rewind is in flight throws. - */ - async rewind(target: number, reason?: string): Promise { - if (this.restorePhase === 'restoring' || this.restorePhase === 'failed') { - throw new BugIndicatingError(`Wire rewind called while phase is ${this.restorePhase}`); - } - if (this.dispatching || this.rewinding) { - throw new BugIndicatingError('Wire rewind re-entered while dispatching or rewinding'); - } - if (!Number.isInteger(target) || target < 0 || target > this.nextRecordIndex) { - throw new WireError(WireErrors.codes.WIRE_INVALID_REWIND_TARGET, `Invalid rewind target ${String(target)}`, { - details: { target, nextRecordIndex: this.nextRecordIndex }, - }); - } - this.rewinding = true; - try { - // Drain the persist queue so every record below `target` is durable - // before the rebuild reads the journal back. - await this.flush(); - await this.rebuildRewindableModels(target, []); - this.appendRecord(createLogCutRecord(target, reason)); - this.nextRecordIndex++; - } finally { - this.rewinding = false; - } - } - - /** - * Reset all rewindable models to their initial state and re-apply journal - * records `[0, target)` to them only (cross-model reducers included; records - * routing to non-rewindable models contribute cross effects but no state). - * Nested `log.cut` records inside the range recurse — the cut's own - * semantics is "rewindable models := fold([0, cut.target))", and replay - * continues after the cut record, exactly like the restore main loop. - * `migrations` lets the restore-time rebuild see the same record view as - * the main replay when the on-disk file predates the current protocol. - */ - private async rebuildRewindableModels( - target: number, - migrations: readonly WireMigration[], - ): Promise { - for (const [def, inst] of this.models) { - if (def.rewindable === true) { - inst.state = Object.freeze(def.initial()); - } - } - let index = 0; - const source = this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY); - for await (const candidate of source) { - if (index >= target) break; - if (!isWireRecord(candidate)) { - index++; - continue; - } - // The metadata envelope never consumes a record index (see - // `nextRecordIndex`), so metadata-less and metadata-ful journals index - // identically. - if (candidate.type === 'metadata') continue; - const record = migrateWireRecord(candidate, migrations); - if (record.type === 'log.cut') { - if (isLogCutRecord(record)) { - await this.rebuildRewindableModels(Math.min(record.target, index), migrations); - } - index++; - continue; - } - this.replayRewindableRecord(record, index); - index++; - } - await this.rehydrateModels(); - } - - /** - * Re-apply one record during a rewind rebuild: the owning model is updated - * only when rewindable; cross-model reducers run only for rewindable - * targets, so non-rewindable (world-time) models are never double-applied. - */ - private replayRewindableRecord(record: WireRecord, index: number): void { - const descriptor = OP_REGISTRY.get(record.type); - if (descriptor === undefined) return; - const payload = descriptor.schema.safeParse(wireRecordToPayload(record)); - if (!payload.success) return; - const ctx: OpApplyContext = { recordIndex: index }; - if (descriptor.model.rewindable === true) { - const inst = this.ensureModel(descriptor.model); - inst.state = Object.freeze(descriptor.apply(inst.state, payload.data, ctx)); - } - const crossReducers = MODEL_CROSS_REDUCERS.get(record.type); - if (crossReducers !== undefined) { - for (const entry of crossReducers) { - if (entry.model.rewindable !== true) continue; - const crossInst = this.ensureModel(entry.model); - crossInst.state = Object.freeze(entry.reducer(crossInst.state, payload.data, ctx)); - } - } - } - private replayRecord(record: WireRecord, index: number): void { const descriptor = OP_REGISTRY.get(record.type); if (descriptor === undefined) { @@ -356,7 +223,7 @@ export class WireService extends Disposable implements IWireService { return; } this.execute({ - ops: [{ type: record.type, payload: payload.data, descriptor, recordIndex: index }], + ops: [{ type: record.type, payload: payload.data, descriptor }], silent: true, }); } @@ -379,15 +246,7 @@ export class WireService extends Disposable implements IWireService { for (const op of group.ops) { const inst = this.ensureModel(op.descriptor.model); const prev = inst.state; - // Journal position of this op's record: replay supplies it; live - // dispatch assigns the next line index for persisted ops. Transient ops - // never occupy a journal line and keep `undefined`. - let recordIndex = op.recordIndex; - if (!group.silent && recordIndex === undefined && op.descriptor.persist !== false) { - recordIndex = this.nextRecordIndex++; - } - const ctx: OpApplyContext = { recordIndex }; - inst.state = Object.freeze(op.descriptor.apply(prev, op.payload, ctx)); + inst.state = Object.freeze(op.descriptor.apply(prev, op.payload)); if (!group.silent) { if (op.descriptor.persist !== false) { const record = opToWireRecord(op); @@ -403,7 +262,7 @@ export class WireService extends Disposable implements IWireService { for (const entry of crossReducers) { if (entry.model === op.descriptor.model) continue; const crossInst = this.ensureModel(entry.model); - crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload, ctx)); + crossInst.state = Object.freeze(entry.reducer(crossInst.state, op.payload)); } } } 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 ee6791d324..710c136b28 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -274,44 +274,3 @@ describe('reduceContextTranscript', () => { expect(result.foldedLength).toBe(4); }); }); - -describe('reduceContextTranscript — log.cut (wire rewind)', () => { - it('drops the record range invalidated by a log.cut', () => { - // Journal: prompt(0), u1(1), a1(2..4), prompt(5), u2(6), a2(7..9), cut(10→5) - const result = reduceContextTranscript([ - { type: 'turn.prompt', input: [{ type: 'text', text: 'u1' }], origin: { kind: 'user' } }, - appendMessage(userMessage('u1')), - ...assistantStep('s1', 'a1'), - { type: 'turn.prompt', input: [{ type: 'text', text: 'u2' }], origin: { kind: 'user' } }, - appendMessage(userMessage('u2')), - ...assistantStep('s2', 'a2'), - { type: 'log.cut', target: 5, reason: 'undo' }, - ]); - expect(texts(result)).toEqual(['u1', 'a1']); - expect(result.foldedLength).toBe(2); - }); - - it('ignores the metadata envelope without giving it a record index', () => { - const result = reduceContextTranscript([ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - appendMessage(userMessage('u1')), - appendMessage(userMessage('u2')), - { type: 'log.cut', target: 1, reason: 'undo' }, - ]); - expect(texts(result)).toEqual(['u1']); - }); - - it('applies nested cuts and keeps records appended after them', () => { - // u2 invalidated by the first cut; u3/u4 by the second; cuts compose. - const result = reduceContextTranscript([ - appendMessage(userMessage('u1')), - appendMessage(userMessage('u2')), - { type: 'log.cut', target: 1, reason: 'undo' }, - appendMessage(userMessage('u3')), - appendMessage(userMessage('u4')), - { type: 'log.cut', target: 3, reason: 'undo' }, - appendMessage(userMessage('u5')), - ]); - expect(texts(result)).toEqual(['u1', 'u5']); - }); -}); diff --git a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts index 5409f30643..ba6aef5624 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/stubs.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/stubs.ts @@ -14,6 +14,7 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; +import { computeUndoCut, type UndoCut } from '#/agent/contextMemory/contextOps'; import type { LoopRecordedEvent } from '#/agent/contextMemory/loopEventFold'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IEventBus } from '#/app/event/eventBus'; @@ -58,6 +59,15 @@ export function stubContextMemory(eventBus?: IEventBus): StubContextMemory { messages.splice(0, deleteCount); publishSplice(eventBus, { start: 0, deleteCount, messages: [] }); }, + undo: (count) => { + const cut = computeUndoCut(messages, count); + if (cut.cutIndex >= 0 && cut.removedCount >= count) { + const deleteCount = messages.length - cut.cutIndex; + messages.splice(cut.cutIndex, deleteCount); + publishSplice(eventBus, { start: cut.cutIndex, deleteCount, messages: [] }); + } + return cut; + }, applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => { const shape = buildContextCompactionShape(messages, input); const previousLength = messages.length; @@ -96,6 +106,9 @@ class StubContextMemoryService implements IAgentContextMemoryService { appendLoopEvent(event: LoopRecordedEvent): void { this.impl.appendLoopEvent(event); } + undo(count: number): UndoCut { + return this.impl.undo(count); + } applyCompaction(input: ContextCompactionInput): ContextCompactionResult { return this.impl.applyCompaction(input); } 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 9c89b13ee4..25aeac1f9f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -44,11 +44,7 @@ function compaction(): ContextMessage { const USER_ORIGIN: ContextMessage['origin'] = { kind: 'user' }; -// `computeUndoCut` / `isFullyUndoable` / `contextUndo` are the LEGACY undo -// semantics, kept only so journals with historical `context.undo` records -// still replay. Live undos go through `rewind` (`log.cut`); see -// `test/agent/rewind/`. -describe('computeUndoCut (legacy context.undo replay semantics)', () => { +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 }); @@ -101,7 +97,7 @@ describe('computeUndoCut (legacy context.undo replay semantics)', () => { }); }); -describe('contextUndo op (legacy record apply)', () => { +describe('contextUndo op', () => { it('slices the history at the cut point, dropping post-cut injections too', () => { const state = [ user(USER_ORIGIN), 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 3c6e1c6109..ea4fd70fe4 100644 --- a/packages/agent-core-v2/test/agent/plan/planOps.test.ts +++ b/packages/agent-core-v2/test/agent/plan/planOps.test.ts @@ -59,20 +59,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([ @@ -93,11 +93,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([ @@ -136,7 +136,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', }); @@ -152,6 +152,6 @@ 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); }); }); diff --git a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts index 17681f9642..7d30abb47d 100644 --- a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts +++ b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts @@ -1,10 +1,14 @@ -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { TurnModel } from '#/agent/loop/turnOps'; import { IAgentPlanService } from '#/agent/plan/plan'; import { PlanModel } from '#/agent/plan/planOps'; import { IAgentRewindService } from '#/agent/rewind/rewind'; +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 { IWireService } from '#/wire/wire'; @@ -14,7 +18,6 @@ import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/st describe('AgentRewindService', () => { let ctx: TestAgentContext; let records: TelemetryRecord[]; - let rewoundEvents: number; afterEach(async () => { try { @@ -26,13 +29,12 @@ describe('AgentRewindService', () => { function setup() { records = []; - rewoundEvents = 0; ctx = createTestAgent(telemetryServices(recordingTelemetry(records))); ctx.get(IAgentContextMemoryService); return ctx; } - it('exposes availability from the turn index', async () => { + it('exposes availability from context history', async () => { setup(); const rewind = ctx.get(IAgentRewindService); expect(rewind.availability()).toEqual({ maxTurns: 0, stoppedAtCompaction: false }); @@ -84,7 +86,7 @@ describe('AgentRewindService', () => { expect(history[1]?.origin?.kind).toBe('compaction_summary'); }); - it('rewinds todos and plan mode together with the undone turns', async () => { + it('restores todos to their pre-turn value', async () => { setup(); const rewind = ctx.get(IAgentRewindService); const wire = ctx.get(IWireService); @@ -94,12 +96,37 @@ describe('AgentRewindService', () => { 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 rewind.rewind(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 rewind = ctx.get(IAgentRewindService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); await ctx.get(IAgentPlanService).enter('plan-x', false); await rewind.rewind(1); - expect(wire.getModel(TodoModel)).toEqual([{ title: 'kept', status: 'pending' }]); - expect(wire.getModel(PlanModel).active).toBe(false); + expect(wire.getModel(PlanModel).current.active).toBe(false); + expect(ctx.get(IAgentTelemetryContextService).get().mode).toBe('agent'); + }); + + it('does not roll back world-time turn bookkeeping', async () => { + setup(); + const rewind = ctx.get(IAgentRewindService); + const wire = ctx.get(IWireService); + ctx.appendTurnExchange('u1', 'a1'); + ctx.appendTurnExchange('u2', 'a2'); + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); + + await rewind.rewind(1); + + expect(wire.getModel(TurnModel).nextTurnId).toBe(2); }); it('publishes context.rewound and tracks conversation_undo', async () => { @@ -116,4 +143,45 @@ describe('AgentRewindService', () => { }); expect(ctx.context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); + + 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 rewound: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.rewound', ({ turns }) => { + rewound.push(turns); + }); + + try { + await expect(ctx.get(IAgentRewindService).rewind(1)).resolves.toBe(1); + + expect(ctx.context.get().map((message) => message.role)).toEqual(['user', 'assistant']); + expect(rewound).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(IAgentRewindService).rewind(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/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 37b468cd05..c7710a28a2 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,7 @@ 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 { IAgentRewindService } from '#/agent/rewind/rewind'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { configServices, @@ -714,6 +715,36 @@ describe('AgentTaskService — notification delivery', () => { } }); + it('re-delivers a terminal task notification removed by undo', 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); + }); + + await ctx.get(IAgentRewindService).rewind(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('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 11eeaf7a1b..f873dfeb97 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -71,7 +71,6 @@ function stubWireService(captureRestoreHook?: (hook: RestoreHook) => void): IWir seal: async () => {}, restore: async () => {}, flush: async () => {}, - rewind: async () => {}, getModel: (model) => model.initial() as never, subscribe: () => toDisposable(() => {}), } as IWireService; diff --git a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index 97051a38b3..aae6c40c54 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -170,6 +170,8 @@ class FakeTaskService implements IAgentTaskService { return result; } + async reconcileNotificationDeliveryAfterUndo(): Promise {} + persistOutput(_taskId: string): void {} async getOutputSnapshot( 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 19578b94e3..9b48a97cb5 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -19,6 +19,7 @@ import { type ContextCompactionInput, type ContextCompactionResult, } from '#/agent/contextMemory/contextMemory'; +import { computeUndoCut } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { HookDefSchema, @@ -102,6 +103,13 @@ function stubContextMemory(): IAgentContextMemoryService & { clear: () => { messages.splice(0); }, + undo: (count) => { + const cut = computeUndoCut(messages, count); + if (cut.cutIndex >= 0 && cut.removedCount >= count) { + messages.splice(cut.cutIndex); + } + return cut; + }, applyCompaction: (input: ContextCompactionInput): ContextCompactionResult => { const shape = buildContextCompactionShape(messages, input); messages.splice(0, messages.length, ...shape.messages); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index dd072576d0..74d7f28cfb 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -845,7 +845,6 @@ function collectScopeSeed( class PersistenceAppendLogStore implements IAppendLogStore { declare readonly _serviceBrand: undefined; private readonly history: WireRecord[] = []; - private historyReadCursor = 0; constructor( private readonly persistence: WireRecordPersistence, @@ -863,16 +862,7 @@ class PersistenceAppendLogStore implements IAppendLogStore { async *read(_scope: string, _key: string): AsyncIterable { for await (const event of this.persistence.read()) { this.onRead(event); - // Capture records entering the journal via reads (seeds injected - // directly into persistence, then surfaced by a restore) WITHOUT - // duplicating records already in the append history at this position — - // a mid-flight re-read of an append-only journal (e.g. `wire.rewind`'s - // rebuild) would otherwise seed resume assertions with duplicates. - const existing = this.history[this.historyReadCursor]; - if (existing === undefined || JSON.stringify(existing) !== JSON.stringify(event)) { - this.history.push(cloneRecord(event)); - } - this.historyReadCursor++; + this.history.push(cloneRecord(event)); yield event as R; } } @@ -1394,12 +1384,6 @@ export class AgentTestContext { }); } - /** - * Append a user prompt the way production does: a `turn.prompt` boundary - * record followed by the user message. Undo/rewind tests must build turns - * through this helper — bare `appendUserMessage` appends have no boundary - * and are invisible to the rewind index. - */ appendUserTurn(text: string): void { this.get(IWireService).dispatch( promptTurn({ input: [{ type: 'text', text }], origin: { kind: 'user' } }), @@ -1531,10 +1515,6 @@ export class AgentTestContext { this.coverUsage(tokenTotal); } - /** - * `appendExchange` with a real `turn.prompt` boundary (see - * `appendUserTurn`): the shape undo/rewind tests need. - */ appendTurnExchange(userText: string, assistantText: string, tokenTotal?: number): void { this.appendUserTurn(userText); this.appendAssistantMessage({ diff --git a/packages/agent-core-v2/test/index.test.ts b/packages/agent-core-v2/test/index.test.ts index 1142d1dcbf..ca1721780d 100644 --- a/packages/agent-core-v2/test/index.test.ts +++ b/packages/agent-core-v2/test/index.test.ts @@ -147,7 +147,9 @@ 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' }, + ]); }); }); diff --git a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 49d4409bd2..831053f8e2 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -572,6 +572,8 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { return result; }, + async reconcileNotificationDeliveryAfterUndo(): Promise {}, + persistOutput(taskId: string): void { persisted.add(taskId); }, 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..7c266e4994 100644 --- a/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts +++ b/packages/agent-core-v2/test/session/todo/sessionTodo.test.ts @@ -97,7 +97,7 @@ function makeFakeAgent(agentId: string): FakeAgent { }, restore: async () => {}, flush: async () => {}, - getModel: () => todoState, + getModel: () => ({ current: todoState, checkpoints: [] }), subscribe: () => toDisposable(() => {}), } as unknown as IWireService; diff --git a/packages/agent-core-v2/test/wire/rewind.test.ts b/packages/agent-core-v2/test/wire/rewind.test.ts deleted file mode 100644 index 882b00dc86..0000000000 --- a/packages/agent-core-v2/test/wire/rewind.test.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createServices } from '#/_base/di/test'; -import { DisposableStore } from '#/_base/di/lifecycle'; -import { contextAppendMessage, ContextModel } from '#/agent/contextMemory/contextOps'; -import type { ContextMessage } from '#/agent/contextMemory/types'; -import { TurnModel } from '#/agent/loop/turnOps'; -import { promptTurn } from '#/agent/loop/turnOps'; -import { TurnIndexModel } from '#/agent/loop/turnIndexOps'; -import { TodoModel, todoSet } from '#/session/todo/todoOps'; -import { WireError } from '#/wire/errors'; -import { LOG_CUT_RECORD_TYPE, type WireRecord } from '#/wire/record'; - -import { recordingWireLog, registerTestAgentWire } from './stubs'; - -const SCOPE = 'wire/rewind-test'; - -function userMessage(text: string): ContextMessage { - return { - role: 'user', - content: [{ type: 'text', text }], - toolCalls: [], - origin: { kind: 'user' }, - }; -} - -function promptRecord(text: string) { - return promptTurn({ input: [{ type: 'text', text }], origin: { kind: 'user' } }); -} - -function buildHost(records: WireRecord[] = []) { - const disposables = new DisposableStore(); - const ix = createServices(disposables); - const wire = registerTestAgentWire(ix, SCOPE, { log: recordingWireLog(records) }); - return { disposables, ix, wire, records }; -} - -describe('WireService rewind (log.cut)', () => { - it('rebuilds rewindable models to the cut and preserves world-time models', async () => { - const { wire, records } = buildHost(); - // Journal: turn.prompt(0), user(1), todo(2), turn.prompt(3), user(4), todo(5) - wire.dispatch(promptRecord('u1')); - wire.dispatch(contextAppendMessage({ message: userMessage('u1') })); - wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'from turn 1', status: 'pending' }] })); - wire.dispatch(promptRecord('u2')); - wire.dispatch(contextAppendMessage({ message: userMessage('u2') })); - wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'from turn 2', status: 'pending' }] })); - - // Cut at the second turn's prompt (record index 3). - await wire.rewind(3, 'undo'); - - // Rewindable: context and todo rebuilt from [0, 3). - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); - expect(wire.getModel(TodoModel)).toEqual([{ title: 'from turn 1', status: 'pending' }]); - expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); - // World-time: the turn counter never rewinds. - expect(wire.getModel(TurnModel).nextTurnId).toBe(2); - // The cut record was appended and occupies the next index. - expect(records.map((r) => r.type)).toEqual([ - 'turn.prompt', - 'context.append_message', - 'tools.update_store', - 'turn.prompt', - 'context.append_message', - 'tools.update_store', - LOG_CUT_RECORD_TYPE, - ]); - expect(records[6]).toMatchObject({ target: 3, reason: 'undo' }); - - // State after the cut accepts new turns on top of the rebuilt base. - wire.dispatch(promptRecord('u3')); - wire.dispatch(contextAppendMessage({ message: userMessage('u3') })); - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1'), userMessage('u3')]); - expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0, 7]); - }); - - it('restore of a journal with log.cut produces the post-rewind state', async () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - { type: 'turn.prompt', input: [{ type: 'text', text: 'u1' }], origin: { kind: 'user' } }, - { type: 'context.append_message', message: userMessage('u1') }, - { type: 'turn.prompt', input: [{ type: 'text', text: 'u2' }], origin: { kind: 'user' } }, - { type: 'context.append_message', message: userMessage('u2') }, - { type: 'log.cut', target: 2, reason: 'undo' }, - ]; - const { wire } = buildHost(records); - await wire.restore(); - - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); - expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); - expect(wire.getModel(TurnModel).nextTurnId).toBe(2); - }); - - it('handles nested cuts: a later cut whose range contains an earlier cut', async () => { - const { wire } = buildHost(); - wire.dispatch(promptRecord('u1')); - wire.dispatch(contextAppendMessage({ message: userMessage('u1') })); - wire.dispatch(promptRecord('u2')); - wire.dispatch(contextAppendMessage({ message: userMessage('u2') })); - // Cut away u2 (records 0..3, cut at 4 targeting 2). - await wire.rewind(2, 'undo'); - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); - - wire.dispatch(promptRecord('u3')); - wire.dispatch(contextAppendMessage({ message: userMessage('u3') })); - // Journal: prompt(0), user(1), prompt(2), user(3), cut(4→2), prompt(5), user(6) - // Cut away everything from the second turn's prompt onward (record 2). - await wire.rewind(2, 'undo'); - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); - expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); - }); - - it('still replays legacy context.undo records (pre-rewind journals)', async () => { - const records: WireRecord[] = [ - { type: 'metadata', protocol_version: '1.5', created_at: 1 }, - { type: 'context.append_message', message: userMessage('u1') }, - { type: 'context.append_message', message: userMessage('u2') }, - { type: 'context.undo', count: 1 }, - ]; - const { wire } = buildHost(records); - await wire.restore(); - expect(wire.getModel(ContextModel)).toEqual([userMessage('u1')]); - }); - - it('rejects an out-of-bounds target and a re-entrant rewind', async () => { - const { wire } = buildHost(); - wire.dispatch(promptRecord('u1')); - - await expect(wire.rewind(-1)).rejects.toThrow(WireError); - await expect(wire.rewind(2)).rejects.toThrow(WireError); - - const first = wire.rewind(1, 'undo'); - await expect(wire.rewind(1, 'undo')).rejects.toThrow(/re-entered/); - await first; - expect(wire.getModel(TurnIndexModel).turnStarts).toEqual([0]); - }); -}); diff --git a/packages/agent-core-v2/test/wire/stubs.ts b/packages/agent-core-v2/test/wire/stubs.ts index f182ab0514..8c5dc22f36 100644 --- a/packages/agent-core-v2/test/wire/stubs.ts +++ b/packages/agent-core-v2/test/wire/stubs.ts @@ -100,7 +100,6 @@ export function stubAgentWire( seal: async () => {}, restore: async () => {}, flush, - rewind: async () => {}, getModel: (model) => model.initial() as never, }; } diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 232bbc0ca1..de7fbf6f49 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -204,11 +204,6 @@ export class AgentTranscriptProjector { // Known limitation: undo/clear projects as a bare 'undo' marker (raw // payload attached); no `items.remove` reconstruction in v1. return [this.markerOp('undo', restOf(event))]; - case 'context.rewound': - // Same limitation as `context.spliced`: the rewind (undo) projects as - // a bare 'undo' marker; consumers re-read via the cold/snapshot path - // (which applies `log.cut` ranges through the transcript reducer). - return [this.markerOp('undo', restOf(event))]; case 'error': return [this.noticeOp('error', event.message, restOf(event))]; case 'warning': diff --git a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts index b43b2bed42..200f7311d4 100644 --- a/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts +++ b/packages/kap-server/src/transport/ws/v1/sessionEventBroadcaster.ts @@ -887,11 +887,6 @@ export class SessionEventBroadcaster { if (handle.id === MAIN_AGENT_ID && event.type === 'context.spliced') { emitLegacyStatus(); } - if (handle.id === MAIN_AGENT_ID && event.type === 'context.rewound') { - // Rewind (undo) shrinks the context like a splice; refresh the - // legacy status snapshot (token counts) for WS subscribers. - emitLegacyStatus(); - } this.onAgentEvent(sessionId, handle.id, projected); }), ]; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 1b6f551ec4..b813d585ae 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -692,6 +692,7 @@ describe('AgentTranscriptProjector', () => { feed(ev({ type: 'compaction.started', trigger: 'auto' })); feed(ev({ type: 'compaction.completed', result: { kept: 3 } })); feed(ev({ type: 'context.spliced', start: 1, deleteCount: 2, messages: [] })); + feed(ev({ type: 'context.rewound', turns: 1 })); const markers = tx .getItems() From 72a4b0e8999054be95515fc8c2c023ba7c34fad4 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 12:07:58 +0800 Subject: [PATCH 3/7] refactor: enhance /undo functionality for consistency and safety, including todo list rollback and improved event handling --- .changeset/undo-rewind-consistency.md | 2 +- .pnpm-store/v11/index.db | Bin 0 -> 8192 bytes .../src/agent/plan/planService.ts | 4 + .../src/session/todo/sessionTodoService.ts | 26 +++- .../test/agent/rewind/rewind.test.ts | 22 +++- .../test/session/todo/sessionTodo.test.ts | 33 +++++ .../src/services/transcript/coreBinding.ts | 59 ++++++++- .../src/services/transcript/coreEventMap.ts | 16 ++- .../test/services/transcript.test.ts | 115 ++++++++++++++++++ 9 files changed, 266 insertions(+), 11 deletions(-) create mode 100644 .pnpm-store/v11/index.db diff --git a/.changeset/undo-rewind-consistency.md b/.changeset/undo-rewind-consistency.md index 8f0a5cfc0f..545293dea1 100644 --- a/.changeset/undo-rewind-consistency.md +++ b/.changeset/undo-rewind-consistency.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Make /undo consistent and safe: undoing now also rolls back the todo list, plan mode, and background-task notifications from the undone turns, no longer corrupts the conversation when used while a turn or compaction is running, and restores the friendly limit message when there is nothing to undo. +Make /undo consistent and safe. \ No newline at end of file diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000000000000000000000000000000000000..044636a65ff67410fad601f0c39978108fefcea0 GIT binary patch literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;0rt5R!Xsx zd-*<+oFpepe$$EEhlalXPCq)NHmfksS%-)*#*-P9XRK%~B>T9;=Xc?(b$^tiS5|q+ zqJcmF0uX=z1Rwwb2tWV=5P$##awu^7v_7h}nsvK|di`yVdUMb_v)cb|%{g=6U0>Kr zkg^>qDAS^Pkk?+mi kUUFZI)hjuq$Cn@g0SG_<0uX=z1Rwwb2tWV=5J(070rxR8{{R30 literal 0 HcmV?d00001 diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 02971460cb..adc0011c77 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -59,6 +59,10 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { this._register( eventBus.subscribe('context.rewound', () => { this.restoreTelemetryMode(); + eventBus.publish({ + type: 'agent.status.updated', + planMode: this.isActive, + }); }), ); diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 67801bbac0..c88daa9d1c 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -14,6 +14,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'; @@ -31,6 +32,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, @@ -82,7 +84,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).current); + const current = wire.getModel(TodoModel).current; + this.lastKnownTodos = current; + this.onDidChangeEmitter.fire(current); } private bindAgent(handle: IAgentScopeHandle): void { @@ -91,6 +95,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.rewound', () => { + 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 { @@ -119,9 +135,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/test/agent/rewind/rewind.test.ts b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts index 7d30abb47d..c00e010cdf 100644 --- a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts +++ b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts @@ -1,3 +1,10 @@ +/** + * Scenario: rewind validation and restoration across conversation-scoped models. + * Responsibility: AgentRewindService 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/rewind/rewind.test.ts + */ + import { afterEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; @@ -109,11 +116,20 @@ describe('AgentRewindService', () => { 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); + }); - await rewind.rewind(1); + try { + await rewind.rewind(1); - expect(wire.getModel(PlanModel).current.active).toBe(false); - expect(ctx.get(IAgentTelemetryContextService).get().mode).toBe('agent'); + 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 () => { 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 7c266e4994..c4274e7fca 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 rewind 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[] = []; @@ -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 rewind 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.rewound', turns: 1 }); + main.eventBus.publish({ type: 'context.rewound', 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/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts index a708ee3ac5..d3a10944a2 100644 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -24,6 +24,7 @@ import { IEventBus, ISessionMetadata, ISessionInteractionService, + ISessionTodoService, MAIN_AGENT_ID, type AgentMeta, type IDisposable, @@ -31,7 +32,13 @@ import { type Interaction, type ISessionScopeHandle, } from '@moonshot-ai/agent-core-v2'; -import type { AgentDescriptor, TranscriptChangeEvent, TranscriptStore } from '@moonshot-ai/transcript'; +import type { + AgentDescriptor, + TranscriptChangeEvent, + TranscriptItem, + TranscriptStore, + TranscriptTurn, +} from '@moonshot-ai/transcript'; import { AgentTranscriptProjector, type ProjectorInteraction } from './coreEventMap'; @@ -75,6 +82,7 @@ export function bindSessionTranscript( ): TranscriptBinding { const agents = session.accessor.get(IAgentLifecycleService); const interactions = session.accessor.get(ISessionInteractionService); + const todos = session.accessor.get(ISessionTodoService); const disposables: IDisposable[] = []; /** Per-agent subscriptions (bus listeners capturing the agent's projector) — disposed with the agent. */ const agentDisposables = new Map(); @@ -147,6 +155,8 @@ export function bindSessionTranscript( const turn = view?.state().turn; return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; }, + rewoundTurnIds: (turns) => + rewoundTurnIds(store.getAgent(agentId)?.getItems() ?? [], turns), }); projectors.set(agentId, projector); } @@ -239,6 +249,17 @@ export function bindSessionTranscript( }), ); + disposables.push( + todos.onDidChange((items) => { + applyOps(MAIN_AGENT_ID, [ + { + op: 'todo.upsert', + todo: { todoId: 'todo', items, updatedAt: new Date().toISOString() }, + }, + ]); + }), + ); + // Interactions already pending at bind time are REGISTERED without frames // (ownership must exist immediately so a resolve arriving before the // deferred seed still routes); their frames land in seedPendingInteractions, @@ -334,6 +355,42 @@ export function bindSessionTranscript( }; } +function rewoundTurnIds( + items: readonly TranscriptItem[], + turns: number, +): readonly string[] { + if (turns <= 0) return []; + + let remaining = turns; + let boundary = 0; + for (let index = items.length - 1; index >= 0; index -= 1) { + const item = items[index]; + if (item?.kind !== 'turn') continue; + if (!isUndoAnchorTurn(item)) continue; + remaining -= 1; + if (remaining === 0) { + boundary = index; + break; + } + } + + return items + .slice(boundary) + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => turn.turnId); +} + +function isUndoAnchorTurn(turn: TranscriptTurn): boolean { + const payload = turn.origin.payload as + | { readonly kind?: unknown; readonly trigger?: unknown } + | undefined; + if (turn.origin.kind === 'user') return payload?.kind !== 'shell_command'; + return ( + (payload?.kind === 'skill_activation' || payload?.kind === 'plugin_command') && + payload.trigger === 'user-slash' + ); +} + export function descriptorFromMeta(agentId: string, meta: AgentMeta | undefined): AgentDescriptor { const parentFromLabels = meta?.labels?.['parentAgentId']; const swarmItem = meta?.labels?.['swarmItem'] ?? meta?.swarmItem; diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index de7fbf6f49..46e2c35473 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -14,9 +14,9 @@ * - `tool.call.delta` / `tool.progress` are NOT projected in v1 (argument and * progress streaming are dropped; `tool.result` carries the terminal * state). Known limitation. - * - `context.spliced` (undo/clear) is projected as a bare 'undo' marker with - * the raw payload — no `items.remove` reconstruction in v1. Known - * limitation. + * - `context.spliced` projects the visible undo marker; the later + * `context.rewound` correction removes the rewound turn suffix through a + * producer-store lookup while preserving world-time entities. * - `error` / `warning` become `marker.upsert{ marker: 'notice' }` and never * enter a step. * - No `swarm.*` / `plan.*` mode-transition events exist on the v2 bus today; @@ -88,11 +88,15 @@ export type ProjectorToolFrameLookup = (toolCallId: string) => ToolFrameRecord | */ export type ProjectorStepOrdinalLookup = (turnId: string) => number | undefined; +/** Resolve the currently materialized turn ids removed by an undo. */ +export type ProjectorRewoundTurnLookup = (turns: number) => readonly string[]; + /** Optional producer-store lookups that let the projector adopt seeded state. */ export interface ProjectorLookups { readonly stepFrames?: ProjectorFrameLookup; readonly toolFrame?: ProjectorToolFrameLookup; readonly stepOrdinal?: ProjectorStepOrdinalLookup; + readonly rewoundTurnIds?: ProjectorRewoundTurnLookup; } interface OpenTextFrame { @@ -201,9 +205,11 @@ export class AgentTranscriptProjector { }), ]; case 'context.spliced': - // Known limitation: undo/clear projects as a bare 'undo' marker (raw - // payload attached); no `items.remove` reconstruction in v1. return [this.markerOp('undo', restOf(event))]; + case 'context.rewound': { + const ids = this.lookups?.rewoundTurnIds?.(event.turns) ?? []; + return ids.length === 0 ? [] : [{ op: 'items.remove', ids }]; + } case 'error': return [this.noticeOp('error', event.message, restOf(event))]; case 'warning': diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index b813d585ae..e61b79c2d2 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -18,6 +18,7 @@ import { ISessionInteractionService, ISessionLifecycleService, ISessionMetadata, + ISessionTodoService, SessionInteractionService, type DomainEvent, type ISessionScopeHandle, @@ -1057,9 +1058,31 @@ describe('bindSessionTranscript', () => { } } + class FakeTodos { + private readonly handlers = new Set<(items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[]) => void>(); + private current: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[] = []; + readonly onDidChange = ( + handler: (items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[]) => void, + ): { dispose: () => void } => { + this.handlers.add(handler); + return { dispose: () => this.handlers.delete(handler) }; + }; + getTodos(): readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[] { + return this.current; + } + setTodos(items: readonly { title: string; status: 'pending' | 'in_progress' | 'done' }[]): void { + this.current = items; + for (const handler of this.handlers) handler(items); + } + clear(): void { + this.setTodos([]); + } + } + function fakeSession( interactions: SessionInteractionService, agents?: FakeAgents, + todos = new FakeTodos(), ): ISessionScopeHandle { return { accessor: { @@ -1074,6 +1097,7 @@ describe('bindSessionTranscript', () => { ); } if (token === ISessionInteractionService) return interactions; + if (token === ISessionTodoService) return todos; if (token === ISessionMetadata) return { read: async () => ({ agents: {} }) }; return undefined; }, @@ -1081,6 +1105,97 @@ describe('bindSessionTranscript', () => { } as unknown as ISessionScopeHandle; } + it('removes the rewound live turn suffix using real user-input anchors', () => { + const interactions = new SessionInteractionService(); + const agents = new FakeAgents(); + const main = agents.add('main'); + const store = new TranscriptStore('s1'); + store.ensureAgent('main').apply([ + { + op: 'turn.upsert', + turn: { + kind: 'turn', + turnId: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + }, + }, + ]); + const ops: TranscriptOperation[] = []; + const binding = bindSessionTranscript( + store, + fakeSession(interactions, agents), + undefined, + (event) => ops.push(...event.ops), + ); + const emitTurn = (turnId: number, origin: Record): void => { + main.bus.emit(ev({ type: 'turn.started', turnId, origin })); + main.bus.emit(ev({ type: 'turn.ended', turnId, reason: 'completed' })); + }; + + emitTurn(1, { kind: 'skill_activation', skillName: 'review', trigger: 'user-slash' }); + emitTurn(2, { kind: 'shell_command' }); + emitTurn(3, { kind: 'plugin_command', pluginId: 'p', commandName: 'c', trigger: 'user-slash' }); + emitTurn(4, { kind: 'task', taskId: 'task-1' }); + main.bus.emit( + ev({ + type: 'task.started', + info: { + taskId: 'task-1', + kind: 'process', + description: 'build', + status: 'running', + detached: true, + startedAt: 1_700_000_000_000, + endedAt: null, + }, + }), + ); + main.bus.emit(ev({ type: 'context.spliced', start: 1, deleteCount: 4, messages: [] })); + main.bus.emit(ev({ type: 'context.rewound', turns: 2 })); + + expect( + store + .getAgent('main') + ?.getItems() + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => turn.turnId), + ).toEqual(['t0']); + expect(store.getAgent('main')?.getTask('task-1')).toMatchObject({ state: 'running' }); + expect( + store + .getAgent('main') + ?.getItems() + .filter((item) => item.kind === 'marker') + .map((marker) => marker.marker), + ).toEqual(['undo']); + expect(ops.filter((op) => op.op === 'items.remove')).toEqual([ + { op: 'items.remove', ids: ['t1', 't2', 't3', 't4'] }, + ]); + binding.dispose(); + }); + + it('projects session Todo changes into the main live transcript', () => { + const todos = new FakeTodos(); + const store = new TranscriptStore('s1'); + const binding = bindSessionTranscript( + store, + fakeSession(new SessionInteractionService(), undefined, todos), + ); + + todos.setTodos([{ title: 'kept', status: 'pending' }]); + expect(store.getAgent('main')?.getTodo('todo')?.items).toEqual([ + { title: 'kept', status: 'pending' }, + ]); + + binding.dispose(); + todos.setTodos([{ title: 'ignored after dispose', status: 'done' }]); + expect(store.getAgent('main')?.getTodo('todo')?.items).toEqual([ + { title: 'kept', status: 'pending' }, + ]); + }); + it('registers pre-bind pendings without frames and replays an early resolve at seed time', () => { const interactions = new SessionInteractionService(); interactions.enqueue({ From 7c1b69ecced3f93cb00c6d47b37d7b0591c940e2 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 12:13:16 +0800 Subject: [PATCH 4/7] chore: clean up undo changeset artifacts --- .changeset/undo-rewind-consistency.md | 2 +- .pnpm-store/v11/index.db | Bin 8192 -> 0 bytes 2 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 .pnpm-store/v11/index.db diff --git a/.changeset/undo-rewind-consistency.md b/.changeset/undo-rewind-consistency.md index 545293dea1..0a751fb58d 100644 --- a/.changeset/undo-rewind-consistency.md +++ b/.changeset/undo-rewind-consistency.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Make /undo consistent and safe. \ No newline at end of file +Fix /undo to restore conversation history, todo lists, plan mode, and task notifications consistently. diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db deleted file mode 100644 index 044636a65ff67410fad601f0c39978108fefcea0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8192 zcmeIuzpBD86bA4#2p0s=&GDX11#$5OY&BppTCFMSqD0LV@%|C%po4=C;0rt5R!Xsx zd-*<+oFpepe$$EEhlalXPCq)NHmfksS%-)*#*-P9XRK%~B>T9;=Xc?(b$^tiS5|q+ zqJcmF0uX=z1Rwwb2tWV=5P$##awu^7v_7h}nsvK|di`yVdUMb_v)cb|%{g=6U0>Kr zkg^>qDAS^Pkk?+mi kUUFZI)hjuq$Cn@g0SG_<0uX=z1Rwwb2tWV=5J(070rxR8{{R30 From 9c48e2ffe49166488139c168c1c3e38168c659eb Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 18:28:14 +0800 Subject: [PATCH 5/7] refactor: rebuild rewind consistency --- packages/agent-core-v2/AGENTS.md | 4 + .../contextMemory/contextMemoryService.ts | 7 +- .../src/agent/contextMemory/contextOps.ts | 18 +- .../agent/contextMemory/contextTranscript.ts | 337 +++++++++++++-- .../conversationReconciliation.ts | 66 +++ .../agent/contextMemory/conversationTime.ts | 69 ++++ .../agent/fullCompaction/fullCompaction.ts | 7 +- .../fullCompaction/fullCompactionService.ts | 16 +- packages/agent-core-v2/src/agent/loop/loop.ts | 2 + .../src/agent/loop/loopService.ts | 91 ++++- .../agent-core-v2/src/agent/loop/turnOps.ts | 75 ++-- .../agent-core-v2/src/agent/plan/planOps.ts | 42 +- .../agent-core-v2/src/agent/prompt/prompt.ts | 7 - .../src/agent/prompt/promptMetadataText.ts | 66 +++ .../src/agent/rewind/rewindService.ts | 201 ++++++--- .../src/agent/rpc/prompt-metadata.ts | 69 +--- .../agent-core-v2/src/agent/rpc/rpcService.ts | 2 - packages/agent-core-v2/src/agent/task/task.ts | 1 - .../src/agent/task/taskService.ts | 220 +++++----- packages/agent-core-v2/src/index.ts | 2 + .../src/session/todo/sessionTodoService.ts | 5 +- .../agent-core-v2/src/session/todo/todoOps.ts | 39 +- .../contextMemory/contextTranscript.test.ts | 366 +++++++++++++++++ .../test/agent/loop/loop.test.ts | 47 +++ .../agent-core-v2/test/agent/loop/stubs.ts | 1 + .../test/agent/rewind/rewind.test.ts | 306 +++++++++++++- .../test/agent/task/rpc-events.test.ts | 63 ++- .../test/agent/task/taskService.test.ts | 13 + .../test/agent/task/tools/task-tools.test.ts | 2 - .../toolSelect/toolSelectService.test.ts | 4 + packages/agent-core-v2/test/harness/agent.ts | 8 + .../os/backends/node-local/tools/bash.test.ts | 2 - packages/agent-core-v2/test/tool/tool.test.ts | 7 + .../agent-core-v2/test/wire/resume.test.ts | 41 ++ .../src/services/transcript/coreBinding.ts | 163 +++++--- .../src/services/transcript/coreEventMap.ts | 15 +- .../services/transcript/transcriptService.ts | 49 ++- .../test/services/transcript.test.ts | 382 +++++++++++++++++- .../test/sessionEventBroadcaster.test.ts | 12 + .../transcript/src/granularity/filterOps.ts | 11 +- packages/transcript/src/history/groupTurns.ts | 181 ++++++++- packages/transcript/test/layers.test.ts | 66 +++ 42 files changed, 2620 insertions(+), 465 deletions(-) create mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts create mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts create mode 100644 packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts diff --git a/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index af3ccf11cc..fbe52e9cda 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 rewind 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/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index 6d71c458d0..94dc6cefff 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -2,8 +2,11 @@ * `contextMemory` domain (L4) — `IAgentContextMemoryService` implementation. * * Owns per-agent conversation history through `wire`, maintains measurements - * with `contextSize`, and broadcasts live mutations through `event`. Bound at - * Agent scope. + * with `contextSize`, and broadcasts live mutations through `event`. Every + * splice-shaped mutation (`clear` / `applyCompaction` / `undo`) publishes + * `context.spliced` from the live path only — replay rebuilds silently — and + * `undo` additionally rebases the measured token prefix to an estimate when + * the cut truncates it. Bound at Agent scope. */ import { Disposable } from '#/_base/di/lifecycle'; diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index 8446efadd0..e9981d0df6 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -22,6 +22,11 @@ * `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record * itself, exactly like v1's restore-time `popMatchedMessage`. * + * `context.undo` counts conversation ticks with the single `isUndoAnchor` + * predicate (`./conversationTime`) — the same definition the checkpoint + * protocol pushes with, so anchor counting and checkpoint pushing can never + * drift apart. + * * Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`: * - `dehydrate(record, transform)`: at dispatch time, traverses message content * in `context.append_message` and `context.append_loop_event` records, @@ -43,6 +48,7 @@ import { createCompactionSummaryMessage, type ContextCompactionShapeInput, } from './compactionHandoff'; +import { isUndoAnchor } from './conversationTime'; import { foldAppendMessage, foldLoopEvent, @@ -304,7 +310,7 @@ export function computeUndoCut(state: readonly ContextMessage[], count: number): stoppedAtCompaction = true; break; } - if (isRealUserPrompt(message)) { + if (isUndoAnchor(message)) { remaining--; removedCount++; cutIndex = i; @@ -361,13 +367,3 @@ export const contextUndo = ContextModel.defineOp('context.undo', { 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..acd7997516 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -1,33 +1,9 @@ /** - * `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 message and transcript consumers with full pre-compaction history, + * folded-context length, and stable turn identity while preserving live + * undo/clear semantics. Scope-agnostic. */ import { type ContentPart, type ToolCall } from '#/kosong/contract/message'; @@ -36,11 +12,11 @@ import type { WireRecord } from '#/wire/record'; import { COMPACT_USER_MESSAGE_MAX_TOKENS, collectCompactableUserMessages, - isRealUserInput, selectRecentUserMessages, } from './compactionHandoff'; +import { isUndoAnchor } from './conversationTime'; import type { LoopRecordedEvent } from './loopEventFold'; -import type { ContextMessage } from './types'; +import type { ContextMessage, PromptOrigin } from './types'; import { isVacuousContentPart } from './vacuousContent'; const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = @@ -49,9 +25,19 @@ const TOOL_INTERRUPTED_ON_RESUME_OUTPUT = export interface ContextTranscript { readonly entries: readonly ContextMessage[]; readonly times: readonly (number | undefined)[]; + readonly turnIds: readonly (number | undefined)[]; + readonly turns: readonly ContextTranscriptTurn[]; + readonly stableTurnIds: boolean; readonly foldedLength: number; } +export interface ContextTranscriptTurn { + readonly turnId: number; + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; + readonly time?: number; +} + export interface ContextTranscriptReducer { add(record: WireRecord): void; result(): ContextTranscript; @@ -70,8 +56,32 @@ interface MutableMessage { interface MutableEntry { message: MutableMessage; time?: number; + turnId?: number; + opensTurn: boolean; + order: number; } +interface MutableTurn extends ContextTranscriptTurn { + readonly order: number; +} + +interface PendingSteerBase { + readonly input: readonly ContentPart[]; + readonly origin: PromptOrigin; + readonly time?: number; + readonly order: number; + readonly expectsMessage: boolean; +} + +type PendingSteer = + | (PendingSteerBase & { readonly state: 'recorded' }) + | (PendingSteerBase & { readonly state: 'message-bound'; readonly entry: MutableEntry }) + | (PendingSteerBase & { + readonly state: 'turn-bound'; + readonly turnId: number; + readonly opensTurn: boolean; + }); + export function reduceContextTranscript(records: Iterable): ContextTranscript { const reducer = createContextTranscriptReducer(); for (const record of records) reducer.add(record); @@ -86,6 +96,15 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const pendingToolResultIds = new Set(); let deferred: MutableEntry[] = []; let lastOpenStepUuid: string | undefined; + let turns: MutableTurn[] = []; + let nextTurnId = 0; + let currentTurnId: number | undefined; + const cancelledTurnIds = new Set(); + let pendingPromptAnchor: { readonly turnId: number } | undefined; + let pendingSteers: PendingSteer[] = []; + let stableTurnIds = true; + let recordOrder = 0; + const toolTurnIds = new Map(); const push = (...entries: MutableEntry[]): void => { transcript.push(...entries); @@ -109,16 +128,25 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { isError: true, }, time, + turnId: toolTurnIds.get(toolCallId), + opensTurn: false, + order: recordOrder, }); pendingToolResultIds.delete(toolCallId); + toolTurnIds.delete(toolCallId); } flushDeferredIfToolExchangeClosed(); }; - const resetOpenState = (): void => { + const resetOpenState = (clearPendingInput = true): void => { openSteps.clear(); pendingToolResultIds.clear(); deferred = []; lastOpenStepUuid = undefined; + toolTurnIds.clear(); + if (clearPendingInput) { + pendingPromptAnchor = undefined; + pendingSteers = []; + } }; const settleStep = (uuid: string): void => { const entry = openSteps.get(uuid); @@ -133,6 +161,21 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { }; const applyLoopEvent = (event: LoopRecordedEvent, time: number | undefined): void => { + const observedTurnId = readLoopTurnId(event); + if (observedTurnId !== undefined) { + resolvePendingSteers(observedTurnId); + currentTurnId = observedTurnId; + nextTurnId = Math.max(nextTurnId, observedTurnId + 1); + if (!turns.some((turn) => turn.turnId === observedTurnId)) { + turns.push({ + turnId: observedTurnId, + input: [], + origin: { kind: 'retry' }, + time, + order: recordOrder, + }); + } + } switch (event.type) { case 'step.begin': { closePendingToolResults(time); @@ -140,6 +183,9 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const entry: MutableEntry = { message: { role: 'assistant', content: [], toolCalls: [] }, time, + turnId: observedTurnId ?? currentTurnId, + opensTurn: false, + order: recordOrder, }; push(entry); openSteps.set(event.uuid, entry); @@ -168,6 +214,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { }; openStep.message.toolCalls.push(call); pendingToolResultIds.add(event.toolCallId); + toolTurnIds.set(event.toolCallId, openStep.turnId); return; } case 'tool.result': { @@ -181,8 +228,12 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { isError: event.result.isError, }, time, + turnId: toolTurnIds.get(event.toolCallId), + opensTurn: false, + order: recordOrder, }); pendingToolResultIds.delete(event.toolCallId); + toolTurnIds.delete(event.toolCallId); flushDeferredIfToolExchangeClosed(); return; } @@ -192,24 +243,146 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { const applyUndo = (count: number): void => { if (count <= 0) return; let removedUserCount = 0; + let cutEntry: MutableEntry | undefined; for (let i = transcript.length - 1; i >= clearFloor; i--) { - const message = transcript[i]!.message; + const entry = transcript[i]!; + const message = entry.message; if (message.origin?.kind === 'injection') continue; 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) { + cutEntry = entry; + break; + } } } + if (cutEntry !== undefined) { + turns = turns.filter( + (turn) => + turn.order < cutEntry.order && + (!cutEntry.opensTurn || turn.turnId !== cutEntry.turnId), + ); + } resetOpenState(); }; const add = (record: WireRecord): void => { + recordOrder += 1; switch (record.type) { + case 'turn.prompt': { + if (pendingSteers.length > 0) stableTurnIds = false; + const input = readTurnInput(record); + const origin = readTurnOrigin(record); + while (cancelledTurnIds.delete(nextTurnId)) nextTurnId += 1; + const turnId = nextTurnId; + nextTurnId += 1; + currentTurnId = turnId; + turns.push({ turnId, input, origin, time: record.time, order: recordOrder }); + pendingSteers = []; + pendingPromptAnchor = + input.length > 0 && + isUndoAnchor({ role: 'user', content: [...input], toolCalls: [], origin }) + ? { turnId } + : undefined; + break; + } + case 'turn.steer': { + const input = readTurnInput(record); + const origin = readTurnOrigin(record); + pendingSteers.push({ + state: 'recorded', + input, + origin, + time: record.time, + order: recordOrder, + expectsMessage: input.length > 0, + }); + break; + } + case 'turn.cancel': { + const turnId = readTurnCancelId(record); + const target = readTurnCancelTarget(record); + if (target === 'queued') { + if (turnId !== undefined && turnId >= nextTurnId) { + cancelledTurnIds.add(turnId); + } + break; + } + if (target === undefined) { + stableTurnIds = false; + pendingSteers = []; + pendingPromptAnchor = undefined; + break; + } + if (pendingSteers.length === 0) { + if (turnId !== undefined && turnId >= nextTurnId) cancelledTurnIds.add(turnId); + pendingPromptAnchor = undefined; + break; + } + if ( + turnId === undefined || + (turnId !== currentTurnId && turnId !== nextTurnId) + ) { + stableTurnIds = false; + pendingSteers = []; + } else { + resolvePendingSteers(turnId, true); + currentTurnId = turnId; + nextTurnId = Math.max(nextTurnId, turnId + 1); + if (!turns.some((turn) => turn.turnId === turnId)) { + turns.push({ + turnId, + input: [], + origin: { kind: 'retry' }, + time: record.time, + order: recordOrder, + }); + } + } + pendingPromptAnchor = undefined; + break; + } case 'context.append_message': { - const entry = toMutableEntry(record['message'] as ContextMessage, record.time); + const message = record['message'] as ContextMessage; + const entry = toMutableEntry( + message, + record.time, + currentTurnId, + false, + recordOrder, + ); + if (isUndoAnchor(message) && pendingPromptAnchor !== undefined) { + entry.turnId = pendingPromptAnchor.turnId; + entry.opensTurn = true; + pendingPromptAnchor = undefined; + const redundantSteer = pendingSteers.findIndex( + (steer) => + steer.state !== 'message-bound' && + steer.expectsMessage && + hasSameOriginKind(steer.origin, message.origin), + ); + if (redundantSteer >= 0) pendingSteers.splice(redundantSteer, 1); + } else { + const steerIndex = pendingSteers.findIndex( + (candidate) => + candidate.state !== 'message-bound' && + candidate.expectsMessage && + hasSameOriginKind(candidate.origin, message.origin), + ); + const steer = pendingSteers[steerIndex]; + if (steer !== undefined) { + if (steer.state === 'turn-bound') { + entry.turnId = steer.turnId; + entry.opensTurn = steer.opensTurn; + pendingSteers.splice(steerIndex, 1); + } else { + pendingSteers[steerIndex] = { ...steer, state: 'message-bound', entry }; + } + } + } if (pendingToolResultIds.size > 0) deferred.push(entry); else push(entry); break; @@ -226,9 +399,12 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { origin: { kind: 'compaction_summary' }, }, time: record.time, + turnId: currentTurnId, + opensTurn: false, + order: recordOrder, }); foldedLength = recoverFoldedLength(record, transcript, clearFloor, foldedLength); - resetOpenState(); + resetOpenState(false); break; } case 'context.undo': @@ -249,12 +425,58 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { result: () => ({ entries: transcript.map((e) => e.message), times: transcript.map((e) => e.time), + turnIds: transcript.map((e) => e.turnId), + turns: turns + .toSorted((a, b) => a.turnId - b.turnId) + .map(({ order: _order, ...turn }) => turn), + stableTurnIds, foldedLength, }), }; + + function resolvePendingSteers(observedTurnId: number, settleUnbound = false): void { + const unresolved = pendingSteers.filter( + (steer) => steer.state !== 'turn-bound' && steer.expectsMessage, + ); + const opensNewTurn = + unresolved.length > 0 && + (currentTurnId === undefined || observedTurnId !== currentTurnId); + if (opensNewTurn) { + const opener = unresolved[0]!; + if (!turns.some((turn) => turn.turnId === observedTurnId)) { + turns.push({ + turnId: observedTurnId, + input: opener.input, + origin: opener.origin, + time: opener.time, + order: opener.order, + }); + } + } + let unresolvedIndex = 0; + pendingSteers = pendingSteers.flatMap((steer) => { + if (steer.state === 'turn-bound') return settleUnbound ? [] : [steer]; + if (!steer.expectsMessage) return []; + const opensTurn = opensNewTurn && unresolvedIndex === 0; + unresolvedIndex += 1; + if (steer.state === 'message-bound') { + steer.entry.turnId = observedTurnId; + steer.entry.opensTurn = opensTurn; + return []; + } + if (settleUnbound) return []; + return [{ ...steer, state: 'turn-bound', turnId: observedTurnId, opensTurn }]; + }); + } } -function toMutableEntry(message: ContextMessage, time: number | undefined): MutableEntry { +function toMutableEntry( + message: ContextMessage, + time: number | undefined, + turnId: number | undefined, + opensTurn: boolean, + order: number, +): MutableEntry { return { message: { ...(message.id !== undefined ? { id: message.id } : {}), @@ -266,9 +488,50 @@ function toMutableEntry(message: ContextMessage, time: number | undefined): Muta ...(message.origin !== undefined ? { origin: message.origin } : {}), }, time, + turnId, + opensTurn, + order, }; } +function readLoopTurnId(event: LoopRecordedEvent): number | undefined { + if (!('turnId' in event) || event.turnId === undefined) return undefined; + const value = Number.parseInt(event.turnId, 10); + return Number.isInteger(value) && value >= 0 ? value : undefined; +} + +function readTurnInput(record: WireRecord): readonly ContentPart[] { + const input = record['input']; + return Array.isArray(input) ? (input as readonly ContentPart[]) : []; +} + +function readTurnOrigin(record: WireRecord): PromptOrigin { + const origin = record['origin']; + if (origin !== null && typeof origin === 'object' && 'kind' in origin) { + return origin as PromptOrigin; + } + return { kind: 'user' }; +} + +function readTurnCancelId(record: WireRecord): number | undefined { + const turnId = record['turnId']; + return typeof turnId === 'number' && Number.isInteger(turnId) && turnId >= 0 + ? turnId + : undefined; +} + +function readTurnCancelTarget(record: WireRecord): 'active' | 'queued' | undefined { + const target = record['target']; + return target === 'active' || target === 'queued' ? target : undefined; +} + +function hasSameOriginKind( + left: PromptOrigin, + right: ContextMessage['origin'], +): boolean { + return left.kind === (right?.kind ?? 'user'); +} + function recoverFoldedLength( record: WireRecord, transcript: readonly MutableEntry[], diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts new file mode 100644 index 0000000000..3fbd7239d0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts @@ -0,0 +1,66 @@ +/** + * `contextMemory` domain (L4) — Agent-scoped post-rewind reconciliation registry. + * + * Hosts state-repair and derived-projection participants for the rewind + * coordinator. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { createDecorator } from '#/_base/di/instantiation'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +export interface AgentConversationReconciliationParticipant { + readonly id: string; + readonly phase?: AgentConversationReconciliationPhase; + reconcileAfterRewind(): Promise; +} + +export type AgentConversationReconciliationPhase = 'state' | 'projection'; + +export interface IAgentConversationReconciliationRegistry { + readonly _serviceBrand: undefined; + + register(participant: AgentConversationReconciliationParticipant): IDisposable; + list(): readonly AgentConversationReconciliationParticipant[]; +} + +export const IAgentConversationReconciliationRegistry = + createDecorator( + 'agentConversationReconciliationRegistry', + ); + +class AgentConversationReconciliationRegistry + extends Disposable + implements IAgentConversationReconciliationRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly participants = new Map(); + + register(participant: AgentConversationReconciliationParticipant): IDisposable { + if (this.participants.has(participant.id)) { + throw new Error( + `Conversation reconciliation 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 AgentConversationReconciliationParticipant[] { + return [...this.participants.values()]; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationReconciliationRegistry, + AgentConversationReconciliationRegistry, + InstantiationType.Eager, + 'conversationReconciliation', +); 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..6847056ad0 --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -0,0 +1,69 @@ +/** + * `contextMemory` domain (L4) — shared conversation clock and checkpointed + * wire-Model factory. + * + * Defines the undo anchor vocabulary and registers conversation-time Models + * for rewind 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 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 (count <= 0 || 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/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index c51905d126..ec2693e7c3 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -4,6 +4,7 @@ import type { } from './types'; import { createDecorator } from "#/_base/di/instantiation"; import type { Event } from '#/_base/event'; +import type { IDisposable } from '#/_base/di/lifecycle'; import type { Hooks } from '#/hooks'; export interface FullCompactionInput { @@ -24,12 +25,8 @@ export interface IAgentFullCompactionService { readonly compacting: FullCompactionTask | null; begin(input: FullCompactionInput): boolean; - /** - * Abort the in-flight compaction (if any) and wait for it to settle. - * A no-op when idle. Used by the rewind pipeline's quiesce step so a - * compaction can never apply a pre-rewind summary onto post-rewind context. - */ cancel(): Promise; + pauseLaunching(): IDisposable; readonly hooks: Hooks<{ onWillCompact: FullCompactionTask; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index 0aeee5e592..e546cc1fd4 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -1,4 +1,4 @@ -import { Disposable } from "#/_base/di/lifecycle"; +import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -114,6 +114,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private consecutiveOverflowCompactions = 0; private activeTurnId: number | undefined; private contextInjectorService: IAgentContextInjectorService | undefined; + private launchPauseDepth = 0; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -247,6 +248,7 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } begin(input: FullCompactionInput): boolean { + if (this.launchPauseDepth > 0) return false; if (this._compacting) return false; const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; if (!this.reserveCompactionSlot(data.source)) return false; @@ -278,10 +280,14 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } try { await active.promise; - } catch { - // The abort path already routed through `cancelActive` (state cleanup + - // `compaction.cancelled`); worker rejections settle here by design. - } + } catch {} + } + + pauseLaunching(): IDisposable { + this.launchPauseDepth += 1; + return toDisposable(() => { + this.launchPauseDepth = Math.max(0, this.launchPauseDepth - 1); + }); } private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index d20a659a16..74cbcdd630 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; + acquireQuiescence(): Promise; + /** 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 3b16df8ca6..5720408c80 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -101,9 +101,12 @@ 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 nextReservedTurnId: number | undefined; private readonly settleWaiters: Array<() => void> = []; + private readonly quiescenceWaiters: Array<() => void> = []; + private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; private lastRequestTraceId: string | undefined; private disposing = false; @@ -131,6 +134,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(); } @@ -141,6 +148,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': @@ -163,10 +182,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 { @@ -199,10 +214,41 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); } + async acquireQuiescence(): Promise { + if (this.disposing) throw abortError('Agent loop disposed'); + this.quiescenceDepth += 1; + const active = this.activeTurnJob; + if (active !== undefined) { + this.cancel(active.turn.id); + await new Promise((resolve) => { + if (this.activeTurnJob === undefined) resolve(); + else this.quiescenceWaiters.push(resolve); + }); + } + 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; } @@ -212,7 +258,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'; @@ -226,12 +272,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) => { @@ -240,7 +291,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(); @@ -296,6 +351,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) { @@ -344,7 +407,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(); @@ -475,6 +538,9 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (step.state === 'queued' || step.state === 'running') step.cancel(reason); } this.activeTurnJob = undefined; + const waiters = this.quiescenceWaiters.splice(0); + for (const resolve of waiters) resolve(); + this.maybeSettle(); } registerLoopErrorHandler( @@ -1013,6 +1079,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 44407910b3..fce78c891d 100644 --- a/packages/agent-core-v2/src/agent/plan/planOps.ts +++ b/packages/agent-core-v2/src/agent/plan/planOps.ts @@ -1,48 +1,26 @@ /** - * `plan` domain (L4) — replayable plan-mode wire state. + * `plan` domain (L4) — persists plan-mode conversation state. * - * Owns the persisted plan lifecycle state consumed by the Agent-scoped - * `planService` and keeps it consistent with conversation undo. + * Keeps plan mode aligned with conversation undo through `contextMemory`'s + * checkpoint protocol and exposes the state consumed by the Agent-scope plan + * service. */ import { z } from 'zod'; -import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; -import { defineModel } from '#/wire/model'; +import { + defineCheckpointedModel, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; export interface PlanState { readonly active: boolean; readonly id?: string; } -export interface PlanModelState { - readonly current: PlanState; - readonly checkpoints: readonly PlanState[]; -} +export type PlanModelState = Checkpointed; -export const PlanModel = defineModel('plan', () => ({ - current: { active: false }, - checkpoints: [], -}), { - reducers: { - 'context.append_message': (state, { message }) => - isRealUserInput(message) - ? { ...state, checkpoints: [...state.checkpoints, state.current] } - : state, - '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 (count <= 0 || state.checkpoints.length < count) return state; - const checkpointIndex = state.checkpoints.length - count; - return { - current: state.checkpoints[checkpointIndex]!, - checkpoints: state.checkpoints.slice(0, checkpointIndex), - }; - }, - }, -}); +export const PlanModel = defineCheckpointedModel('plan', (): PlanState => ({ active: false })); export const planModeEnter = PlanModel.defineOp('plan_mode.enter', { schema: z.object({ id: z.string() }), diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 11748e4b1a..528f2d8ae7 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -57,13 +57,6 @@ export interface IAgentPromptService { inject(message: ContextMessage): Promise; retry(): Promise; clear(): void; - /** - * Suspend launching queued prompts until the returned handle is disposed - * (launching resumes, draining the queue, on the last release). The rewind - * pipeline holds this across its quiesce→cut window so an aborted active - * turn cannot auto-start the next queued prompt mid-rewind. Pending prompts - * stay queued. - */ pauseLaunching(): IDisposable; 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..2d9c0ed5e7 --- /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 rewind 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/rewind/rewindService.ts b/packages/agent-core-v2/src/agent/rewind/rewindService.ts index 2700907e1b..4496ae65e3 100644 --- a/packages/agent-core-v2/src/agent/rewind/rewindService.ts +++ b/packages/agent-core-v2/src/agent/rewind/rewindService.ts @@ -1,31 +1,44 @@ /** * `rewind` domain (L6) — `IAgentRewindService` implementation. * - * Coordinates `prompt`, `loop`, and `fullCompaction`; persists history changes - * through `contextMemory`; reconciles `task` delivery and `sessionMetadata`; - * and reports through `telemetry` and `event`. Bound at Agent scope. + * Owns conversation rewind coordination and restored observable state. + * Coordinates `contextMemory`, conversation reconciliation, `fullCompaction`, + * `loop`, `prompt`, Agent and Session identity, `sessionMetadata`, `event`, + * `eventBus`, `telemetry`, and `wire`. Bound at Agent scope. */ -import { Disposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; +import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { + IAgentConversationReconciliationRegistry, + type AgentConversationReconciliationPhase, +} from '#/agent/contextMemory/conversationReconciliation'; import { computeUndoCut, formatUndoUnavailableMessage, precheckUndo, } from '#/agent/contextMemory/contextOps'; +import { + CHECKPOINTED_MODELS, + isUndoAnchor, + 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 { IAgentTaskService } from '#/agent/task/task'; +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 { IAgentRewindService, type RewindAvailability } from './rewind'; @@ -38,81 +51,175 @@ declare module '#/app/event/eventBus' { export class AgentRewindService extends Disposable implements IAgentRewindService { declare readonly _serviceBrand: undefined; + private rewindQueue: Promise = Promise.resolve(); + constructor( @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentFullCompactionService private readonly fullCompaction: IAgentFullCompactionService, @IAgentPromptService private readonly prompt: IAgentPromptService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, - @IAgentTaskService private readonly tasks: IAgentTaskService, + @IAgentConversationReconciliationRegistry + private readonly participants: IAgentConversationReconciliationRegistry, + @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(): RewindAvailability { const cut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); - return { maxTurns: cut.removedCount, stoppedAtCompaction: cut.stoppedAtCompaction }; + const maxTurns = Math.min(cut.removedCount, this.checkpointDepth()); + return { + maxTurns, + stoppedAtCompaction: cut.stoppedAtCompaction || maxTurns < cut.removedCount, + }; } async rewind(turns: number): Promise { if (turns <= 0) return 0; + this.assertUndoAvailable(turns); + const run = this.rewindQueue.then(() => this.rewindNow(turns)); + this.rewindQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + } + + private async rewindNow(turns: number): Promise { + this.assertUndoAvailable(turns); const pause = this.prompt.pauseLaunching(); + const compactionPause = this.fullCompaction.pauseLaunching(); + let quiescence: IDisposable | undefined; try { - if (this.loop.status().state === 'running') { - this.loop.cancel(this.loop.status().activeTurnId); - } - await this.loop.settled(); - await this.fullCompaction.cancel(); - - 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, - }, - }, - ); - } + [quiescence] = await Promise.all([ + this.loop.acquireQuiescence(), + this.fullCompaction.cancel(), + ]); + this.assertUndoAvailable(turns); this.context.undo(turns); - await this.tasks.reconcileNotificationDeliveryAfterUndo().catch(() => {}); - await this.reconcileLastPrompt().catch(() => {}); + await this.flushAfterCommit('context cut'); + await this.reconcileParticipants('state'); + await this.flushAfterCommit('state reconciliation'); + await this.reconcileParticipants('projection'); + await this.flushAfterCommit('projection reconciliation'); + await this.reconcileLastPromptSafely(); this.telemetry.track2('conversation_undo', { count: turns }); this.eventBus.publish({ type: 'context.rewound', turns }); return turns; } finally { + compactionPause.dispose(); + quiescence?.dispose(); pause.dispose(); } } - private async reconcileLastPrompt(): Promise { - const history = this.context.get(); - for (let i = history.length - 1; i >= 0; i--) { - const message = history[i]!; - if (!isRealUserInput(message)) continue; - const text = message.content - .filter((part) => part.type === 'text') - .map((part) => ('text' in part ? part.text : '')) - .join('\n'); - await this.metadata.update({ lastPrompt: text }); - this.eventService.publish({ - type: 'session.meta.updated', - payload: { - agentId: 'main', - sessionId: this.session.sessionId, - patch: { lastPrompt: text }, + private checkpointDepth(): number { + let depth = Number.POSITIVE_INFINITY; + for (const def of CHECKPOINTED_MODELS) { + const state = this.wire.getModel(def) as Checkpointed; + depth = Math.min(depth, state.checkpoints.length); + } + return depth; + } + + 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 = this.checkpointDepth(); + if (depth >= turns) return; + throw new Error2( + ErrorCodes.SESSION_UNDO_UNAVAILABLE, + formatUndoUnavailableMessage({ + ok: false, + reason: 'compaction_boundary', + requested: turns, + undoable: depth, + }), + { + details: { + reason: 'compaction_boundary', + requestedCount: turns, + undoableCount: depth, }, + }, + ); + } + + private async reconcileParticipants(phase: AgentConversationReconciliationPhase): Promise { + const participants = this.participants + .list() + .filter((participant) => (participant.phase ?? 'state') === phase); + const results = await Promise.allSettled( + participants.map((participant) => participant.reconcileAfterRewind()), + ); + results.forEach((result, index) => { + if (result.status === 'fulfilled') return; + this.log.error('rewind participant reconciliation failed', { + participantId: participants[index]?.id, + error: result.reason, }); - return; + }); + } + + private async reconcileLastPromptSafely(): Promise { + try { + await this.reconcileLastPrompt(); + } catch (error) { + this.log.error('rewind lastPrompt reconciliation failed', { error }); + } + } + + private async flushAfterCommit(stage: string): Promise { + try { + await this.wire.flush(); + } catch (error) { + this.log.error('rewind wire flush failed after in-memory commit', { stage, 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 }, + }, + }); } } 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 0584e11431..189c4190d2 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -130,8 +130,6 @@ export class AgentRPCService implements IAgentRPCService { } async undoHistory(payload: UndoHistoryPayload): Promise { - // The rewind service owns the operation end-to-end (quiesce, cut, - // reconcile, telemetry) — this is a thin pass-through. return this.rewind.rewind(payload.count); } diff --git a/packages/agent-core-v2/src/agent/task/task.ts b/packages/agent-core-v2/src/agent/task/task.ts index 5d430da471..9e091502fa 100644 --- a/packages/agent-core-v2/src/agent/task/task.ts +++ b/packages/agent-core-v2/src/agent/task/task.ts @@ -83,7 +83,6 @@ export interface IAgentTaskService { registerTask(task: AgentTask, options?: RegisterAgentTaskOptions): string; getTask(taskId: string): AgentTaskInfo | undefined; list(activeOnly?: boolean, limit?: number): readonly AgentTaskInfo[]; - reconcileNotificationDeliveryAfterUndo(): Promise; persistOutput(taskId: string): void; getOutputSnapshot( taskId: string, diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index ff54ff2b32..2e48d3b6c1 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -1,30 +1,12 @@ /** * `task` domain (L5) — `AgentTaskService` implementation. * - * Owns the agent's registry of running and restored tasks: - * registers and drives tasks to completion, retains a bounded output ring, - * persists task state and output through task persistence rooted at the - * agent's own scope (v1's per-agent `/agents//tasks/` - * layout), lets only the main agent read through the previous v2 - * session-level task root without writing back to it, reads - * limits through `config`, records lifecycle and broadcasts through `wire` - * (`task.started` / `task.terminated` Ops into `TaskModel`, plus the matching - * signals), restores ghosts through a single `wire.hooks.onDidRestore` hook - * (wire replay -> disk load -> reconcile, in that order), delivers live - * terminal notifications by enqueueing `TaskNotificationStepRequest`s onto - * `loop` with `activeOrNewTurn` admission (mid-turn ones fold into the active turn's - * following step; idle ones launch a fresh turn themselves, matching v1's - * `turn.steer`, so the model consumes the notification without waiting for - * the user), silently appends restored notifications through `contextMemory`, - * re-surfaces active tasks through `contextInjector` after compaction, and - * requests every owned task to stop on session close (`stopAllOnExit` — v1's - * `stopBackgroundTasksOnExit`) with configurable SIGTERM grace and SIGKILL - * escalation. `keepAliveOnExit` skips task-manager teardown so independently - * living external work such as processes can continue; Session-scoped agents - * remain governed by the Session lifecycle. Scope disposal paths that bypass - * graceful close synchronously cancel/abort work and immediately attempt a - * best-effort force-stop to reduce the risk of surviving child processes. - * Bound at Agent scope. + * Owns task lifecycle, retained output, persistence, restoration, and terminal + * notification delivery. Coordinates App-level task handles, `loop`, + * `contextMemory`, `contextInjector`, `config`, Agent and Session identity, + * atomic-document and file storage, `eventBus`, `wire`, and `telemetry`; + * notification delivery follows conversation undo through the checkpoint and + * reconciliation contracts. Bound at Agent scope. */ import { randomBytes } from 'node:crypto'; @@ -36,13 +18,15 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import type { ContentPart } from '#/kosong/contract/message'; import { Disposable } from '#/_base/di/lifecycle'; +import { ILogService } from '#/_base/log/log'; import { abortable, userCancellationReason, } from '#/_base/utils/abort'; import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; -import { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; +import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime'; +import { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -62,7 +46,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, @@ -108,39 +91,15 @@ interface AgentTaskNotificationBuildContext { readonly notification: AgentTaskNotification; } -interface TaskNotificationDeliveryState { - readonly current: readonly string[]; - readonly checkpoints: readonly (readonly string[])[]; -} - -const TaskNotificationDeliveryModel = defineModel( +const TaskNotificationDeliveryModel = defineCheckpointedModel( 'task.notificationDelivery', - () => ({ current: [], checkpoints: [] }), + (): readonly string[] => [], { - reducers: { - 'context.append_message': (state, { message }) => { - if (isRealUserInput(message)) { - return { ...state, checkpoints: [...state.checkpoints, state.current] }; - } - const origin = taskOriginFromMessage(message); - if (origin === undefined) return state; - const key = notificationKey(origin); - return state.current.includes(key) - ? state - : { ...state, current: [...state.current, key] }; - }, - '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 (count <= 0 || state.checkpoints.length < count) return state; - const checkpointIndex = state.checkpoints.length - count; - return { - current: state.checkpoints[checkpointIndex]!, - checkpoints: state.checkpoints.slice(0, checkpointIndex), - }; - }, + onAppendMessage: (current, message) => { + const origin = taskOriginFromMessage(message); + if (origin === undefined) return current; + const key = notificationKey(origin); + return current.includes(key) ? current : [...current, key]; }, }, ); @@ -222,7 +181,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, @@ -230,6 +192,10 @@ export class TaskNotificationStepRequest extends MessageStepRequest { admission: 'activeOrNewTurn', }); } + + override onWillMaterialize(): void { + this.onWillDeliver?.(); + } } export class AgentTaskService extends Disposable implements IAgentTaskService { @@ -237,8 +203,10 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { private readonly tasks = new Map(); private readonly ghosts = new Map(); + private readonly buildingNotificationKeys = new Set(); private readonly scheduledNotificationKeys = new Set(); private readonly deliveredNotificationKeys = new Set(); + private readonly pendingNotificationRequests = new Map(); private readonly persistence: AgentTaskPersistence; private notificationRestoreQueue: Promise = Promise.resolve(); private activeTaskReminderPending = false; @@ -256,6 +224,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IEventBus private readonly eventBus: IEventBus, @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loop: IAgentLoopService, + @IAgentConversationReconciliationRegistry + conversationReconciliation: IAgentConversationReconciliationRegistry, + @ILogService private readonly log: ILogService, ) { super(); const fallbackRoot = @@ -269,6 +240,12 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { byteStore, fallbackRoot, ); + this._register( + conversationReconciliation.register({ + id: 'task.notificationDelivery', + reconcileAfterRewind: () => this.reconcileNotificationDeliveryAfterUndo(), + }), + ); this._register( this.wire.hooks.onDidRestore.register('task', async (_ctx, next) => { for (const key of this.wire.getModel(TaskNotificationDeliveryModel).current) { @@ -494,13 +471,18 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { return result; } - async reconcileNotificationDeliveryAfterUndo(): Promise { + private async reconcileNotificationDeliveryAfterUndo(): Promise { const restoredKeys = new Set(this.wire.getModel(TaskNotificationDeliveryModel).current); - for (const key of this.deliveredNotificationKeys) { - if (!restoredKeys.has(key)) this.scheduledNotificationKeys.delete(key); + 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(); } @@ -1010,7 +992,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); } @@ -1035,14 +1019,31 @@ 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 restoreAgentTaskNotifications(): Promise { @@ -1084,35 +1085,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 { @@ -1135,7 +1152,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/index.ts b/packages/agent-core-v2/src/index.ts index 5297a92ef2..fb3a3849e3 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -386,6 +386,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/conversationReconciliation'; +export * from '#/agent/contextMemory/conversationTime'; export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; export * from '#/agent/contextMemory/messageProjection'; diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index c88daa9d1c..dc952b35b8 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -3,7 +3,10 @@ * * Provides session-wide todo access through the main agent's `wire`, binds * todo capabilities into each agent, and publishes changes through its typed - * event. Bound at Session scope. + * 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. */ import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index 651511b3d2..cf036d029d 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -1,42 +1,23 @@ /** - * `todo` domain (L4) — replayable wire state for the shared todo list. + * `todo` domain (L4) — persists the session's shared todo document. * - * Owns validated todo state consumed by the Session-scoped - * `SessionTodoService` and keeps it consistent with conversation undo. + * 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 { isRealUserInput } from '#/agent/contextMemory/compactionHandoff'; -import { defineModel } from '#/wire/model'; +import { + defineCheckpointedModel, + type Checkpointed, +} from '#/agent/contextMemory/conversationTime'; import { readTodoItems, type TodoItem } from './todoItem'; -export interface TodoModelState { - readonly current: readonly TodoItem[]; - readonly checkpoints: readonly (readonly TodoItem[])[]; -} +export type TodoModelState = Checkpointed; -export const TodoModel = defineModel('todo', () => ({ current: [], checkpoints: [] }), { - reducers: { - 'context.append_message': (state, { message }) => - isRealUserInput(message) - ? { ...state, checkpoints: [...state.checkpoints, state.current] } - : state, - '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 (count <= 0 || state.checkpoints.length < count) return state; - const checkpointIndex = state.checkpoints.length - count; - return { - current: state.checkpoints[checkpointIndex]!, - checkpoints: state.checkpoints.slice(0, checkpointIndex), - }; - }, - }, -}); +export const TodoModel = defineCheckpointedModel('todo', (): readonly TodoItem[] => []); declare module '#/wire/types' { interface PersistedOpMap { 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..0af1a089a3 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -67,6 +67,22 @@ function undo(count: number): WireRecord { return { type: 'context.undo', count }; } +function promptTurn(input: string, origin: PromptOrigin): WireRecord { + return { type: 'turn.prompt', input: [{ type: 'text', text: input }], origin }; +} + +function steerTurn(input: string): WireRecord { + return { + type: 'turn.steer', + input: [{ type: 'text', text: input }], + origin: { kind: 'user' }, + }; +} + +function emptySteerTurn(): WireRecord { + return { type: 'turn.steer', input: [], origin: { kind: 'user' } }; +} + function texts(result: ContextTranscript): string[] { return result.entries.map((m) => m.content.map((p) => (p.type === 'text' ? p.text : `[${p.type}]`)).join(''), @@ -172,6 +188,356 @@ describe('reduceContextTranscript', () => { expect(texts(result)).toEqual(['message A', 'reply A']); }); + it('keeps stable turn ids when undo removes a steer after a hidden retry turn', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ + type: 'content.part', + stepUuid: 's0', + turnId: '0', + part: { type: 'text', text: 'a0' }, + }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + promptTurn('', { kind: 'retry' }), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ + type: 'content.part', + stepUuid: 's1', + turnId: '1', + part: { type: 'text', text: 'retry answer' }, + }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + promptTurn('u2', { kind: 'user' }), + appendMessage(userMessage('u2', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ + type: 'content.part', + stepUuid: 's2', + turnId: '2', + part: { type: 'text', text: 'before steer' }, + }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + steerTurn('remove me'), + appendMessage(userMessage('remove me', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's3', turnId: '2' }), + loopEvent({ + type: 'content.part', + stepUuid: 's3', + turnId: '2', + part: { type: 'text', text: 'after steer' }, + }), + loopEvent({ type: 'step.end', uuid: 's3', turnId: '2' }), + undo(1), + ]); + + expect(texts(result)).toEqual(['u0', 'a0', 'retry answer', 'u2', 'before steer']); + expect(result.turnIds).toEqual([0, 0, 1, 2, 2]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0, 1, 2]); + }); + + it('promotes a legacy idle steer to the new turn reported by its loop events', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ + type: 'content.part', + stepUuid: 's0', + turnId: '0', + part: { type: 'text', text: 'a0' }, + }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('u1'), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ + type: 'content.part', + stepUuid: 's1', + turnId: '1', + part: { type: 'text', text: 'a1' }, + }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + ]); + + expect(texts(result)).toEqual(['u0', 'a0', 'u1', 'a1']); + expect(result.turnIds).toEqual([0, 0, 1, 1]); + expect(result.turns).toMatchObject([ + { turnId: 0, input: [{ type: 'text', text: 'u0' }], origin: { kind: 'user' } }, + { turnId: 1, input: [{ type: 'text', text: 'u1' }], origin: { kind: 'user' } }, + ]); + }); + + it('removes the promoted legacy idle-steer turn when its anchor is undone', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ + type: 'content.part', + stepUuid: 's0', + turnId: '0', + part: { type: 'text', text: 'a0' }, + }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('u1'), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ + type: 'content.part', + stepUuid: 's1', + turnId: '1', + part: { type: 'text', text: 'a1' }, + }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + undo(1), + ]); + + expect(texts(result)).toEqual(['u0', 'a0']); + expect(result.turnIds).toEqual([0, 0]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); + }); + + it.each(['messages-first', 'event-first'] as const)( + 'assigns multiple same-origin legacy steers FIFO when %s', + (order) => { + const steers = [steerTurn('u1'), steerTurn('u2')]; + const messages = [ + appendMessage(userMessage('u1', { kind: 'user' })), + appendMessage(userMessage('u2', { kind: 'user' })), + ]; + const stepBegin = loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }); + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + ...steers, + ...(order === 'messages-first' ? [...messages, stepBegin] : [stepBegin, ...messages]), + loopEvent({ + type: 'content.part', + stepUuid: 's1', + turnId: '1', + part: { type: 'text', text: 'a1' }, + }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + ]); + + const idsByText = new Map(texts(result).map((text, index) => [text, result.turnIds[index]])); + expect(idsByText.get('u1')).toBe(1); + expect(idsByText.get('u2')).toBe(1); + expect(idsByText.get('a1')).toBe(1); + expect(result.turns[1]).toMatchObject({ + turnId: 1, + input: [{ type: 'text', text: 'u1' }], + }); + }, + ); + + it('does not retain an empty steer after observing its turn', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + emptySteerTurn(), + loopEvent({ type: 'step.begin', uuid: 'empty', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 'empty', turnId: '0' }), + steerTurn('u1'), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + ]); + + expect(result.turnIds).toEqual([0, 1]); + expect(result.turns).toMatchObject([ + { turnId: 0, input: [{ type: 'text', text: 'u0' }] }, + { turnId: 1, input: [{ type: 'text', text: 'u1' }] }, + ]); + expect(result.stableTurnIds).toBe(true); + }); + + it('uses the message-producing steer as the opener when an empty steer precedes it', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + emptySteerTurn(), + steerTurn('u1'), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + promptTurn('u2', { kind: 'user' }), + appendMessage(userMessage('u2', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + ]); + + expect(result.turnIds).toEqual([0, 1, 2]); + expect(result.turns).toMatchObject([ + { turnId: 0, input: [{ type: 'text', text: 'u0' }] }, + { turnId: 1, input: [{ type: 'text', text: 'u1' }] }, + { turnId: 2, input: [{ type: 'text', text: 'u2' }] }, + ]); + expect(result.stableTurnIds).toBe(true); + }); + + it('removes the message-producing steer turn on undo when an empty steer preceded it', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + emptySteerTurn(), + steerTurn('u1'), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + undo(1), + ]); + + expect(texts(result)).toEqual(['u0']); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); + }); + + it('uses an active cancel turn id to resolve an idle steer before its first loop event', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('cancelled'), + appendMessage(userMessage('cancelled', { kind: 'user' })), + { type: 'turn.cancel', turnId: 1, target: 'active' }, + promptTurn('u2', { kind: 'user' }), + appendMessage(userMessage('u2', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + ]); + + expect(result.turnIds).toEqual([0, 1, 2]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0, 1, 2]); + expect(result.turns[1]).toMatchObject({ + input: [{ type: 'text', text: 'cancelled' }], + origin: { kind: 'user' }, + }); + expect(result.stableTurnIds).toBe(true); + }); + + it('marks an id-less legacy cancel as ambiguous instead of exposing partial stable ids', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('cancelled'), + appendMessage(userMessage('cancelled', { kind: 'user' })), + { type: 'turn.cancel' }, + promptTurn('next', { kind: 'user' }), + appendMessage(userMessage('next', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + ]); + + expect(result.stableTurnIds).toBe(false); + }); + + it('does not create a phantom turn for an orphan legacy cancel id', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + { type: 'turn.cancel', turnId: 99 }, + promptTurn('u1', { kind: 'user' }), + appendMessage(userMessage('u1', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's1', turnId: '1' }), + loopEvent({ type: 'step.end', uuid: 's1', turnId: '1' }), + ]); + + expect(result.turnIds).toEqual([0, 1]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0, 1]); + expect(result.stableTurnIds).toBe(false); + }); + + it('preserves a queued-turn id gap after its cancel record', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + { type: 'turn.cancel', turnId: 1, target: 'queued' }, + promptTurn('u2', { kind: 'user' }), + appendMessage(userMessage('u2', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + ]); + + expect(result.turnIds).toEqual([0, 2]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0, 2]); + expect(result.stableTurnIds).toBe(true); + }); + + it('keeps an active-turn steer separate from a concurrently cancelled queued turn', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('steered into active'), + appendMessage(userMessage('steered into active', { kind: 'user' })), + { type: 'turn.cancel', turnId: 1, target: 'queued' }, + loopEvent({ type: 'step.begin', uuid: 's0-next', turnId: '0' }), + loopEvent({ + type: 'content.part', + stepUuid: 's0-next', + turnId: '0', + part: { type: 'text', text: 'active answer' }, + }), + loopEvent({ type: 'step.end', uuid: 's0-next', turnId: '0' }), + promptTurn('u2', { kind: 'user' }), + appendMessage(userMessage('u2', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's2', turnId: '2' }), + loopEvent({ type: 'step.end', uuid: 's2', turnId: '2' }), + ]); + + expect(result.turnIds).toEqual([0, 0, 0, 2]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0, 2]); + expect(result.stableTurnIds).toBe(true); + }); + + it('marks a legacy cancel ambiguous when a pending steer could belong to another turn', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('pending'), + appendMessage(userMessage('pending', { kind: 'user' })), + { type: 'turn.cancel', turnId: 1 }, + ]); + + expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); + expect(result.stableTurnIds).toBe(false); + }); + + it('marks a cancel id ambiguous when it cannot resolve a pending steer', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + loopEvent({ type: 'step.begin', uuid: 's0', turnId: '0' }), + loopEvent({ type: 'step.end', uuid: 's0', turnId: '0' }), + steerTurn('pending'), + appendMessage(userMessage('pending', { kind: 'user' })), + { type: 'turn.cancel', turnId: 99 }, + ]); + + expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); + expect(result.stableTurnIds).toBe(false); + }); + it('undo stops at a compaction summary', () => { const result = reduceContextTranscript([ appendMessage(userMessage('old')), 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 3fe4f6b895..de9150ef4a 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,53 @@ describe('Agent loop', () => { expect(ctx.llmCalls).toHaveLength(3); }); + it('holds new admissions until the active turn is quiescent and the lease is released', async () => { + let started!: () => void; + const activeStarted = new Promise((resolve) => { + started = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-quiescence', async (hookCtx, next) => { + started(); + await new Promise((_, reject) => { + hookCtx.signal.addEventListener('abort', () => reject(hookCtx.signal.reason), { once: true }); + }); + await next(); + }); + + const active = (await loop.enqueue(nextTurnMessage('active')).assigned).turn; + await activeStarted; + const leasePromise = loop.acquireQuiescence(); + const held = loop.enqueue(nextTurnMessage('held')); + let assigned = false; + void held.assigned.then(() => { + assigned = true; + }); + + const lease = await leasePromise; + await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); + await Promise.resolve(); + expect(assigned).toBe(false); + expect(loop.status()).toMatchObject({ state: 'idle', hasPendingRequests: true }); + + hook.dispose(); + ctx.mockNextResponse({ type: 'text', text: 'after rewind' }); + 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 = await loop.acquireQuiescence(); + 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 6191e48afc..619a036e65 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -69,6 +69,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; }, + acquireQuiescence: async () => 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/rewind/rewind.test.ts b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts index c00e010cdf..aaeceaa224 100644 --- a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts +++ b/packages/agent-core-v2/test/agent/rewind/rewind.test.ts @@ -8,9 +8,15 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; +import { contextApplyCompaction } from '#/agent/contextMemory/contextOps'; +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 { IAgentRewindService } from '#/agent/rewind/rewind'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; @@ -67,6 +73,50 @@ describe('AgentRewindService', () => { }); }); + it('does not interrupt an active turn when undo is unavailable', 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-rewind', 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; + + await expect(ctx.get(IAgentRewindService).rewind(1)).rejects.toMatchObject({ + code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, + details: { reason: 'empty' }, + }); + expect(turn.signal.aborted).toBe(false); + expect(loop.status().state).toBe('running'); + + hook.dispose(); + release(); + await expect(turn.result).resolves.toMatchObject({ type: 'completed' }); + }); + it('refuses to cross a compaction boundary', async () => { setup(); const rewind = ctx.get(IAgentRewindService); @@ -86,20 +136,32 @@ describe('AgentRewindService', () => { }); await rewind.rewind(1); - // The compaction shape keeps the recent user message plus the summary; - // turn 2 is gone. 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 rewind = ctx.get(IAgentRewindService); + 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(rewind.rewind(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('restores todos to their pre-turn value', async () => { setup(); const rewind = ctx.get(IAgentRewindService); const wire = ctx.get(IWireService); ctx.appendTurnExchange('u1', 'a1'); - // The session todo facade is not wired to the harness agent lifecycle; - // dispatch the todo ops directly (the facade's own target wire anyway). wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'kept', status: 'pending' }] })); ctx.appendTurnExchange('u2', 'a2'); wire.dispatch(todoSet({ key: 'todo', value: [{ title: 'doomed', status: 'pending' }] })); @@ -145,6 +207,198 @@ describe('AgentRewindService', () => { expect(wire.getModel(TurnModel).nextTurnId).toBe(2); }); + it('flushes state reconciliation before rebuilding projections', 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(IAgentConversationReconciliationRegistry); + participants.register({ + id: 'test.state', + reconcileAfterRewind: async () => { + order.push('state'); + }, + }); + participants.register({ + id: 'test.projection', + phase: 'projection', + reconcileAfterRewind: async () => { + order.push('projection'); + }, + }); + const subscription = ctx.get(IEventBus).subscribe('context.rewound', () => { + order.push('context.rewound'); + }); + ctx.appendTurnExchange('u1', 'a1'); + + try { + await ctx.get(IAgentRewindService).rewind(1); + + expect(order).toEqual([ + 'flush', + 'state', + 'flush', + 'projection', + 'flush', + 'context.rewound', + ]); + } finally { + subscription.dispose(); + flush.mockRestore(); + } + }); + + it.each([1, 2, 3])( + 'finishes the committed rewind when post-cut flush %i fails', + async (failureCall) => { + setup(); + const wire = ctx.get(IWireService); + const originalFlush = wire.flush.bind(wire); + let flushCalls = 0; + const flush = vi.spyOn(wire, 'flush').mockImplementation(async () => { + flushCalls += 1; + if (flushCalls === failureCall) throw new Error('storage unavailable'); + await originalFlush(); + }); + const reconciled: string[] = []; + const participants = ctx.get(IAgentConversationReconciliationRegistry); + participants.register({ + id: 'test.flush-failure-state', + reconcileAfterRewind: async () => { + reconciled.push('state'); + }, + }); + participants.register({ + id: 'test.flush-failure-projection', + phase: 'projection', + reconcileAfterRewind: async () => { + reconciled.push('projection'); + }, + }); + const rewound: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.rewound', ({ turns }) => { + rewound.push(turns); + }); + ctx.appendTurnExchange('u1', 'a1'); + + try { + await expect(ctx.get(IAgentRewindService).rewind(1)).resolves.toBe(1); + expect(ctx.context.get()).toEqual([]); + expect(reconciled).toEqual(['state', 'projection']); + expect(rewound).toEqual([1]); + } finally { + subscription.dispose(); + flush.mockRestore(); + } + }, + ); + + it('prevents compaction from starting until rewind reconciliation finishes', async () => { + setup(); + const compaction = ctx.get(IAgentFullCompactionService); + let beginAccepted: boolean | undefined; + ctx.get(IAgentConversationReconciliationRegistry).register({ + id: 'test.compaction-admission', + reconcileAfterRewind: async () => { + beginAccepted = compaction.begin({ source: 'manual' }); + }, + }); + ctx.appendTurnExchange('u1', 'a1'); + + await ctx.get(IAgentRewindService).rewind(1); + + expect(beginAccepted).toBe(false); + expect(() => compaction.begin({ source: 'manual' })).toThrowError( + expect.objectContaining({ code: ErrorCodes.COMPACTION_UNABLE }), + ); + }); + + it('re-enables compaction before releasing held loop work', async () => { + setup(); + const order: string[] = []; + const compaction = ctx.get(IAgentFullCompactionService); + const originalPauseLaunching = compaction.pauseLaunching.bind(compaction); + const pauseLaunching = vi.spyOn(compaction, 'pauseLaunching').mockImplementation(() => { + const lease = originalPauseLaunching(); + return { + dispose: () => { + order.push('compaction'); + lease.dispose(); + }, + }; + }); + const loop = ctx.get(IAgentLoopService); + const originalAcquireQuiescence = loop.acquireQuiescence.bind(loop); + const acquireQuiescence = vi.spyOn(loop, 'acquireQuiescence').mockImplementation(async () => { + const lease = await originalAcquireQuiescence(); + return { + dispose: () => { + order.push('loop'); + lease.dispose(); + }, + }; + }); + ctx.appendTurnExchange('u1', 'a1'); + + try { + await ctx.get(IAgentRewindService).rewind(1); + expect(order).toEqual(['compaction', 'loop']); + } finally { + acquireQuiescence.mockRestore(); + pauseLaunching.mockRestore(); + } + }); + + it('serializes concurrent rewinds through projection 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(IAgentConversationReconciliationRegistry).register({ + id: 'test.serial-projection', + phase: 'projection', + reconcileAfterRewind: async () => { + calls += 1; + active += 1; + maxActive = Math.max(maxActive, active); + if (calls === 1) { + markFirstStarted(); + await firstBlocked; + } + active -= 1; + }, + }); + + const first = ctx.get(IAgentRewindService).rewind(1); + await firstStarted; + const second = ctx.get(IAgentRewindService).rewind(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.rewound and tracks conversation_undo', async () => { setup(); ctx.get(IAgentRewindService); @@ -160,6 +414,50 @@ describe('AgentRewindService', () => { 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(IAgentRewindService).rewind(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(IAgentRewindService).rewind(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'); 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 c7710a28a2..7509783034 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,7 @@ 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 { IAgentRewindService } from '#/agent/rewind/rewind'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { @@ -715,7 +716,7 @@ describe('AgentTaskService — notification delivery', () => { } }); - it('re-delivers a terminal task notification removed by undo', async () => { + 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 { @@ -732,6 +733,9 @@ describe('AgentTaskService — notification delivery', () => { await vi.waitFor(() => { expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(1); }); + vi.spyOn(manager, 'getOutputSnapshot').mockRejectedValueOnce( + new Error('output unavailable'), + ); await ctx.get(IAgentRewindService).rewind(1); @@ -745,6 +749,63 @@ describe('AgentTaskService — notification delivery', () => { } }); + it('re-delivers a queued notification cancelled by rewind before materialization', async () => { + const fixture = createAgentTaskService(); + const { ctx, manager } = fixture; + const loop = ctx.get(IAgentLoopService); + let markStarted!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-notification-rewind', async (hookCtx, next) => { + markStarted(); + await new Promise((_, reject) => { + hookCtx.signal.addEventListener('abort', () => reject(hookCtx.signal.reason), { once: true }); + }); + 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 ctx.get(IAgentRewindService).rewind(1); + + await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); + 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 { + 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 f873dfeb97..062ce0584b 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 { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; import { IAgentContextInjectorService, type ContextInjectionContext, @@ -45,6 +47,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'; @@ -87,6 +90,11 @@ describe('AgentTaskService', () => { ix = disposables.add(new TestInstantiationService()); eventBus = disposables.add(new EventBusService()); injectionProviders = new Map(); + ix.stub(ILogService, stubLog()); + ix.stub(IAgentConversationReconciliationRegistry, { + register: () => toDisposable(() => {}), + list: () => [], + }); ix.stub(IWireService, stubWireService()); ix.stub(IEventBus, eventBus); ix.stub(IAgentContextInjectorService, { @@ -393,6 +401,11 @@ describe('AgentTaskService', () => { captureRestoreHook?: (hook: RestoreHook) => void, ): TestInstantiationService { const ix = disposables.add(new TestInstantiationService()); + ix.stub(ILogService, stubLog()); + ix.stub(IAgentConversationReconciliationRegistry, { + 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/task/tools/task-tools.test.ts b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts index aae6c40c54..97051a38b3 100644 --- a/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts +++ b/packages/agent-core-v2/test/agent/task/tools/task-tools.test.ts @@ -170,8 +170,6 @@ class FakeTaskService implements IAgentTaskService { return result; } - async reconcileNotificationDeliveryAfterUndo(): Promise {} - persistOutput(_taskId: string): void {} async getOutputSnapshot( 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 cf63a8ecef..929bdb5fbc 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -223,6 +223,10 @@ class FakeLoopService implements IAgentLoopService { throw new Error('unused in this suite'); } + async acquireQuiescence(): Promise { + return toDisposable(() => {}); + } + hasPendingRequests(): boolean { return false; } diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index 74d7f28cfb..480d9bc7bc 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'; @@ -348,6 +349,7 @@ interface ResumeStateSnapshot { readonly context: { readonly history: readonly ContextMessage[]; }; + readonly checkpointedModels: Readonly>; readonly permission: Omit, 'rules'>; readonly usage: Omit, 'currentTurn'>; } @@ -2128,6 +2130,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/os/backends/node-local/tools/bash.test.ts b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts index 831053f8e2..49d4409bd2 100644 --- a/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts +++ b/packages/agent-core-v2/test/os/backends/node-local/tools/bash.test.ts @@ -572,8 +572,6 @@ function createFakeTaskService(options: { maxRunningTasks?: number } = {}): { return result; }, - async reconcileNotificationDeliveryAfterUndo(): Promise {}, - persistOutput(taskId: string): void { persisted.add(taskId); }, diff --git a/packages/agent-core-v2/test/tool/tool.test.ts b/packages/agent-core-v2/test/tool/tool.test.ts index f12bec39d3..3529f874fa 100644 --- a/packages/agent-core-v2/test/tool/tool.test.ts +++ b/packages/agent-core-v2/test/tool/tool.test.ts @@ -231,6 +231,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..8891649afb 100644 --- a/packages/agent-core-v2/test/wire/resume.test.ts +++ b/packages/agent-core-v2/test/wire/resume.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it, vi } from 'vitest'; import { WIRE_PROTOCOL_VERSION, IAgentGoalService, + reduceContextTranscript, type WireRecord, type PromptOrigin, } from '#/index'; @@ -170,6 +171,46 @@ 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 }); + const transcript = reduceContextTranscript(persistence.records); + expect(transcript.turnIds).toEqual([0, 2, 2]); + expect(transcript.stableTurnIds).toBe(true); + }); + it('projects restored pending tool results before later user messages', async () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts index d3a10944a2..c0ad862646 100644 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -21,6 +21,8 @@ import { IAgentLifecycleService, IAgentActivityView, + IAgentConversationReconciliationRegistry, + IAgentContextMemoryService, IEventBus, ISessionMetadata, ISessionInteractionService, @@ -33,12 +35,13 @@ import { type ISessionScopeHandle, } from '@moonshot-ai/agent-core-v2'; import type { + AgentTranscriptSnapshot, AgentDescriptor, - TranscriptChangeEvent, TranscriptItem, + TranscriptChangeEvent, TranscriptStore, - TranscriptTurn, } from '@moonshot-ai/transcript'; +import { groupMessagesIntoSnapshot } from '@moonshot-ai/transcript'; import { AgentTranscriptProjector, type ProjectorInteraction } from './coreEventMap'; @@ -79,6 +82,8 @@ export function bindSessionTranscript( * state-style/idempotent, so replaying a locally-unchanged upsert is safe. */ onOps?: (event: TranscriptChangeEvent) => void, + onRewind?: (agentId: string) => void, + loadRewoundSnapshot?: (agentId: string) => Promise, ): TranscriptBinding { const agents = session.accessor.get(IAgentLifecycleService); const interactions = session.accessor.get(ISessionInteractionService); @@ -102,6 +107,8 @@ export function bindSessionTranscript( const earlyResolves = new Map(); /** Agents whose pendings may announce live (their backfill+seed has run). */ const seededAgents = new Set(); + /** Projection outcomes waiting for their matching `context.rewound` event. */ + const rewoundProjections = new Map(); let seededAll = false; const isSeeded = (agentId: string): boolean => seededAll || seededAgents.has(agentId); @@ -155,8 +162,6 @@ export function bindSessionTranscript( const turn = view?.state().turn; return turn === undefined || `t${turn.turnId}` !== turnId ? undefined : turn.step; }, - rewoundTurnIds: (turns) => - rewoundTurnIds(store.getAgent(agentId)?.getItems() ?? [], turns), }); projectors.set(agentId, projector); } @@ -175,9 +180,70 @@ export function bindSessionTranscript( // Agent-scoped, so this sees only this agent's events. The subscription is // tracked per agent and disposed with it — the listener captures the // projector, so a dead agent must not keep projecting into the store. - const bus = handle.accessor.get(IEventBus); - const busD = bus.subscribe((event) => applyOps(handle.id, projector.map(event))); const list = agentDisposables.get(handle.id) ?? []; + if (loadRewoundSnapshot !== undefined) { + const reconciliation = handle.accessor.get(IAgentConversationReconciliationRegistry); + list.push( + reconciliation.register({ + id: 'transcript.projection', + phase: 'projection', + reconcileAfterRewind: async () => { + onRewind?.(handle.id); + let projected = false; + try { + const rebuilt = await loadRewoundSnapshot(handle.id); + if (rebuilt !== undefined) { + const transcript = store.ensureAgent(handle.id); + applyOps(handle.id, [ + { + op: 'reset', + agentId: handle.id, + snapshot: conversationResetSnapshot(rebuilt, transcript.snapshot()), + }, + ]); + projected = true; + } + } catch (error) { + logger?.warn( + { + sessionId: store.sessionId, + agentId: handle.id, + err: error instanceof Error ? error.message : error, + }, + 'transcript: post-rewind projection failed, falling back to live context', + ); + } finally { + const outcomes = rewoundProjections.get(handle.id) ?? []; + outcomes.push(projected); + rewoundProjections.set(handle.id, outcomes); + } + }, + }), + ); + } + const bus = handle.accessor.get(IEventBus); + const busD = bus.subscribe((event) => { + if (event.type === 'context.rewound') { + const outcomes = rewoundProjections.get(handle.id); + const projected = outcomes?.shift(); + if (outcomes?.length === 0) rewoundProjections.delete(handle.id); + if (projected === true) return; + if (projected === undefined) onRewind?.(handle.id); + const transcript = store.ensureAgent(handle.id); + const rebuilt = groupMessagesIntoSnapshot( + handle.accessor.get(IAgentContextMemoryService).get(), + ); + applyOps(handle.id, [ + { + op: 'reset', + agentId: handle.id, + snapshot: conversationResetSnapshot(rebuilt, transcript.snapshot()), + }, + ]); + return; + } + applyOps(handle.id, projector.map(event)); + }); list.push(busD); agentDisposables.set(handle.id, list); }; @@ -245,20 +311,22 @@ export function bindSessionTranscript( agentDisposables.delete(agentId); subscribedAgents.delete(agentId); projectors.delete(agentId); + rewoundProjections.delete(agentId); store.markDisposed(agentId, new Date().toISOString()); }), ); - disposables.push( - todos.onDidChange((items) => { - applyOps(MAIN_AGENT_ID, [ - { - op: 'todo.upsert', - todo: { todoId: 'todo', items, updatedAt: new Date().toISOString() }, - }, - ]); - }), - ); + const projectTodos = (items: ReturnType): void => { + applyOps(MAIN_AGENT_ID, [ + { + op: 'todo.upsert', + todo: { todoId: 'todo', items, updatedAt: new Date().toISOString() }, + }, + ]); + }; + disposables.push(todos.onDidChange(projectTodos)); + const initialTodos = todos.getTodos(); + if (initialTodos.length > 0) projectTodos(initialTodos); // Interactions already pending at bind time are REGISTERED without frames // (ownership must exist immediately so a resolve arriving before the @@ -351,44 +419,47 @@ export function bindSessionTranscript( knownInteractions.clear(); unseeded.clear(); earlyResolves.clear(); + rewoundProjections.clear(); }, }; } -function rewoundTurnIds( - items: readonly TranscriptItem[], - turns: number, -): readonly string[] { - if (turns <= 0) return []; - - let remaining = turns; - let boundary = 0; - for (let index = items.length - 1; index >= 0; index -= 1) { - const item = items[index]; - if (item?.kind !== 'turn') continue; - if (!isUndoAnchorTurn(item)) continue; - remaining -= 1; - if (remaining === 0) { - boundary = index; - break; - } +function conversationResetSnapshot( + rebuilt: AgentTranscriptSnapshot, + current: AgentTranscriptSnapshot, +): AgentTranscriptSnapshot { + const attachments = new Map( + rebuilt.attachments.map((attachment) => [attachment.attachmentId, attachment]), + ); + for (const attachment of current.attachments) { + attachments.set(attachment.attachmentId, attachment); } - - return items - .slice(boundary) - .filter((item): item is TranscriptTurn => item.kind === 'turn') - .map((turn) => turn.turnId); + return { + ...rebuilt, + items: preserveTaskRefs(rebuilt.items, current.items), + tasks: current.tasks, + interactions: current.interactions, + attachments: [...attachments.values()], + todos: current.todos, + meta: current.meta, + }; } -function isUndoAnchorTurn(turn: TranscriptTurn): boolean { - const payload = turn.origin.payload as - | { readonly kind?: unknown; readonly trigger?: unknown } - | undefined; - if (turn.origin.kind === 'user') return payload?.kind !== 'shell_command'; - return ( - (payload?.kind === 'skill_activation' || payload?.kind === 'plugin_command') && - payload.trigger === 'user-slash' +function preserveTaskRefs( + rebuilt: readonly TranscriptItem[], + current: readonly TranscriptItem[], +): readonly TranscriptItem[] { + const ids = new Set(rebuilt.map(transcriptItemId)); + const taskRefs = current.filter( + (item) => item.kind === 'taskref' && !ids.has(item.refId), ); + return taskRefs.length === 0 ? rebuilt : [...rebuilt, ...taskRefs]; +} + +function transcriptItemId(item: TranscriptItem): string { + if (item.kind === 'turn') return item.turnId; + if (item.kind === 'marker') return item.markerId; + return item.refId; } export function descriptorFromMeta(agentId: string, meta: AgentMeta | undefined): AgentDescriptor { diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index 46e2c35473..e2955b8cd9 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -14,9 +14,8 @@ * - `tool.call.delta` / `tool.progress` are NOT projected in v1 (argument and * progress streaming are dropped; `tool.result` carries the terminal * state). Known limitation. - * - `context.spliced` projects the visible undo marker; the later - * `context.rewound` correction removes the rewound turn suffix through a - * producer-store lookup while preserving world-time entities. + * - `context.spliced` projects the visible undo marker. The session binding + * handles `context.rewound` as a full conversation projection reset. * - `error` / `warning` become `marker.upsert{ marker: 'notice' }` and never * enter a step. * - No `swarm.*` / `plan.*` mode-transition events exist on the v2 bus today; @@ -88,15 +87,11 @@ export type ProjectorToolFrameLookup = (toolCallId: string) => ToolFrameRecord | */ export type ProjectorStepOrdinalLookup = (turnId: string) => number | undefined; -/** Resolve the currently materialized turn ids removed by an undo. */ -export type ProjectorRewoundTurnLookup = (turns: number) => readonly string[]; - /** Optional producer-store lookups that let the projector adopt seeded state. */ export interface ProjectorLookups { readonly stepFrames?: ProjectorFrameLookup; readonly toolFrame?: ProjectorToolFrameLookup; readonly stepOrdinal?: ProjectorStepOrdinalLookup; - readonly rewoundTurnIds?: ProjectorRewoundTurnLookup; } interface OpenTextFrame { @@ -206,10 +201,8 @@ export class AgentTranscriptProjector { ]; case 'context.spliced': return [this.markerOp('undo', restOf(event))]; - case 'context.rewound': { - const ids = this.lookups?.rewoundTurnIds?.(event.turns) ?? []; - return ids.length === 0 ? [] : [{ op: 'items.remove', ids }]; - } + case 'context.rewound': + return []; case 'error': return [this.noticeOp('error', event.message, restOf(event))]; case 'warning': diff --git a/packages/kap-server/src/services/transcript/transcriptService.ts b/packages/kap-server/src/services/transcript/transcriptService.ts index 51c7af06d1..5ffb430d95 100644 --- a/packages/kap-server/src/services/transcript/transcriptService.ts +++ b/packages/kap-server/src/services/transcript/transcriptService.ts @@ -101,6 +101,7 @@ export class TranscriptService { private readonly opsListeners = new Map void>>(); /** Debounced post-turn heals: `${sessionId}:${agentId}` → pending ordinals + timer. */ private readonly healTimers = new Map; timer: NodeJS.Timeout }>(); + private readonly projectionGenerations = new Map(); constructor(private readonly deps: TranscriptServiceDeps) { // Live entries must not outlive their session: once it closes or archives, @@ -130,8 +131,13 @@ export class TranscriptService { const store = new TranscriptStore(sessionId); let binding: TranscriptBinding; try { - binding = bindSessionTranscript(store, session, this.deps.logger, (event) => - this.handleLiveOps(sessionId, event), + binding = bindSessionTranscript( + store, + session, + this.deps.logger, + (event) => this.handleLiveOps(sessionId, event), + (agentId) => this.invalidateAgentProjection(sessionId, agentId), + (agentId) => this.readColdSnapshot(sessionId, agentId), ); } catch (error) { // The session's core scope can be disposed mid-bind during shutdown @@ -221,6 +227,7 @@ export class TranscriptService { * without colliding. */ private async backfillAgent(sessionId: string, store: TranscriptStore, agentId: string): Promise { + const generation = this.projectionGeneration(sessionId, agentId); let snapshot: AgentTranscriptSnapshot | undefined; try { snapshot = await this.readColdSnapshot(sessionId, agentId); @@ -231,7 +238,10 @@ export class TranscriptService { ); } // The entry may have been dropped (session closed) while reading from disk. - if (this.live.get(sessionId)?.store !== store) return; + if ( + this.live.get(sessionId)?.store !== store || + this.projectionGeneration(sessionId, agentId) !== generation + ) return; const transcript = store.ensureAgent(agentId); if (snapshot !== undefined) { // Turns merge live-first (`healTurnOps`): ops the projector landed @@ -341,6 +351,20 @@ export class TranscriptService { this.healTimers.set(key, { ordinals, timer }); } + private projectionGeneration(sessionId: string, agentId: string): number { + return this.projectionGenerations.get(`${sessionId}:${agentId}`) ?? 0; + } + + private invalidateAgentProjection(sessionId: string, agentId: string): void { + const key = `${sessionId}:${agentId}`; + this.projectionGenerations.set(key, this.projectionGeneration(sessionId, agentId) + 1); + const heal = this.healTimers.get(key); + if (heal !== undefined) { + clearTimeout(heal.timer); + this.healTimers.delete(key); + } + } + /** * A backfill rebuilds every turn as 'completed' — the cold grouping cannot * see in-flight work. When the agent's loop is actually mid-turn, re-assert @@ -394,6 +418,7 @@ export class TranscriptService { ): Promise { const entry = this.live.get(sessionId); if (entry === undefined) return; + const generation = this.projectionGeneration(sessionId, agentId); let snapshot: AgentTranscriptSnapshot | undefined; try { snapshot = await this.readColdSnapshot(sessionId, agentId); @@ -405,7 +430,11 @@ export class TranscriptService { return; } // The entry may have been dropped (session closed) while reading from disk. - if (snapshot === undefined || this.live.get(sessionId)?.store !== entry.store) return; + if ( + snapshot === undefined || + this.live.get(sessionId)?.store !== entry.store || + this.projectionGeneration(sessionId, agentId) !== generation + ) return; const transcript = entry.store.getAgent(agentId); if (transcript === undefined) return; const ops: TranscriptOperation[] = []; @@ -480,8 +509,13 @@ export class TranscriptService { } throw error; } - const messages = [...reduceContextTranscript(records).entries]; - return groupMessagesIntoSnapshot(messages); + const transcript = reduceContextTranscript(records); + return groupMessagesIntoSnapshot( + transcript.entries, + transcript.stableTurnIds + ? { turnIds: transcript.turnIds, turns: transcript.turns } + : undefined, + ); } /** Dispose the live store + binding for a session (session closed / server shutdown). */ @@ -493,6 +527,9 @@ export class TranscriptService { this.healTimers.delete(key); } } + for (const key of this.projectionGenerations.keys()) { + if (key.startsWith(`${sessionId}:`)) this.projectionGenerations.delete(key); + } const entry = this.live.get(sessionId); if (entry === undefined) return; this.live.delete(sessionId); diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index e61b79c2d2..2d6e420251 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -12,6 +12,8 @@ import { join } from 'node:path'; import { IAgentLifecycleService, + IAgentConversationReconciliationRegistry, + IAgentContextMemoryService, IAgentLoopService, IEventBus, ISessionIndex, @@ -21,12 +23,14 @@ import { ISessionTodoService, SessionInteractionService, type DomainEvent, + type ContextMessage, type ISessionScopeHandle, type Scope, } from '@moonshot-ai/agent-core-v2'; import { AgentTranscript, TranscriptStore, + groupMessagesIntoSnapshot, type AgentTranscriptSnapshot, type AppendOp, type FrameUpsertOp, @@ -35,7 +39,7 @@ import { type TranscriptTask, type TranscriptTurn, } from '@moonshot-ai/transcript'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { bindSessionTranscript } from '../../src/services/transcript/coreBinding'; import { AgentTranscriptProjector } from '../../src/services/transcript/coreEventMap'; @@ -1012,9 +1016,41 @@ describe('bindSessionTranscript', () => { interface FakeAgentHandle { readonly id: string; readonly bus: FakeBus; + reconcile(phase: 'state' | 'projection'): Promise; readonly accessor: { get: (token: unknown) => unknown }; } + class FakeReconciliationRegistry { + private readonly participants = new Map< + string, + { + readonly phase?: 'state' | 'projection'; + reconcileAfterRewind(): Promise; + } + >(); + register(participant: { + readonly id: string; + readonly phase?: 'state' | 'projection'; + reconcileAfterRewind(): Promise; + }): { dispose: () => void } { + this.participants.set(participant.id, participant); + return { + dispose: () => { + if (this.participants.get(participant.id) === participant) { + this.participants.delete(participant.id); + } + }, + }; + } + async reconcile(phase: 'state' | 'projection'): Promise { + await Promise.all( + [...this.participants.values()] + .filter((participant) => (participant.phase ?? 'state') === phase) + .map((participant) => participant.reconcileAfterRewind()), + ); + } + } + class FakeAgents { private readonly handles = new Map(); private readonly createHandlers = new Set<(handle: FakeAgentHandle) => void>(); @@ -1033,17 +1069,26 @@ describe('bindSessionTranscript', () => { this.disposeHandlers.add(cb); return { dispose: () => this.disposeHandlers.delete(cb) }; } - add(id: string, opts?: { loopStatus?: unknown }): FakeAgentHandle { + add( + id: string, + opts?: { loopStatus?: unknown; history?: readonly ContextMessage[] }, + ): FakeAgentHandle { const bus = new FakeBus(); + const reconciliation = new FakeReconciliationRegistry(); const handle: FakeAgentHandle = { id, bus, + reconcile: (phase) => reconciliation.reconcile(phase), accessor: { get: (token: unknown) => { if (token === IEventBus) return bus; + if (token === IAgentConversationReconciliationRegistry) return reconciliation; if (token === IAgentLoopService) { return { status: () => opts?.loopStatus ?? { state: 'idle' } }; } + if (token === IAgentContextMemoryService) { + return { get: () => opts?.history ?? [] }; + } return undefined; }, }, @@ -1105,10 +1150,24 @@ describe('bindSessionTranscript', () => { } as unknown as ISessionScopeHandle; } - it('removes the rewound live turn suffix using real user-input anchors', () => { + it('resets conversation items from the authoritative post-rewind context', () => { const interactions = new SessionInteractionService(); const agents = new FakeAgents(); - const main = agents.add('main'); + const main = agents.add('main', { + history: [ + { + role: 'user', + content: [{ type: 'text', text: 'kept' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'answer' }], + toolCalls: [], + }, + ], + }); const store = new TranscriptStore('s1'); store.ensureAgent('main').apply([ { @@ -1163,27 +1222,276 @@ describe('bindSessionTranscript', () => { .map((turn) => turn.turnId), ).toEqual(['t0']); expect(store.getAgent('main')?.getTask('task-1')).toMatchObject({ state: 'running' }); + expect(store.getAgent('main')?.getItems().filter((item) => item.kind === 'marker')).toEqual([]); + expect(ops.filter((op) => op.op === 'reset')).toHaveLength(1); + expect(ops.filter((op) => op.op === 'items.remove')).toEqual([]); + binding.dispose(); + }); + + it('does not infer the rewind cut from the displayed turn count', () => { + const interactions = new SessionInteractionService(); + const agents = new FakeAgents(); + const main = agents.add('main', { + history: [ + { + role: 'user', + content: [{ type: 'text', text: 'kept' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + ], + }); + const store = new TranscriptStore('s1'); + store.ensureAgent('main').apply([ + { + op: 'turn.upsert', + turn: { + kind: 'turn', + turnId: 't0', + ordinal: 0, + state: 'completed', + origin: { kind: 'user' }, + }, + }, + ]); + const ops: TranscriptOperation[] = []; + const binding = bindSessionTranscript( + store, + fakeSession(interactions, agents), + undefined, + (event) => ops.push(...event.ops), + ); + main.bus.emit(ev({ type: 'turn.started', turnId: 1, origin: { kind: 'user' } })); + main.bus.emit(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); + + main.bus.emit(ev({ type: 'context.spliced', start: 0, deleteCount: 3, messages: [] })); + main.bus.emit(ev({ type: 'context.rewound', turns: 5 })); + expect( store .getAgent('main') ?.getItems() - .filter((item) => item.kind === 'marker') - .map((marker) => marker.marker), - ).toEqual(['undo']); - expect(ops.filter((op) => op.op === 'items.remove')).toEqual([ - { op: 'items.remove', ids: ['t1', 't2', 't3', 't4'] }, + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => turn.turnId), + ).toEqual(['t0']); + expect(ops.filter((op) => op.op === 'reset')).toHaveLength(1); + expect(ops.filter((op) => op.op === 'items.remove')).toEqual([]); + binding.dispose(); + }); + + it('rebuilds the post-rewind projection from full journal history after compaction', async () => { + const interactions = new SessionInteractionService(); + const agents = new FakeAgents(); + const foldedHistory: ContextMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'before compaction' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'user', + content: [{ type: 'text', text: 'summary' }], + toolCalls: [], + origin: { kind: 'compaction_summary' }, + }, + { + role: 'user', + content: [{ type: 'text', text: 'surviving turn' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'surviving answer' }], + toolCalls: [], + }, + ]; + const main = agents.add('main', { history: foldedHistory }); + const fullHistory: ContextMessage[] = [ + foldedHistory[0]!, + { + role: 'assistant', + content: [{ type: 'text', text: 'checking old state' }], + toolCalls: [ + { + type: 'function', + id: 'call-old', + name: 'Read', + arguments: '{"path":"/old"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'old tool result' }], + toolCalls: [], + toolCallId: 'call-old', + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'old answer' }], + toolCalls: [], + }, + ...foldedHistory.slice(1), + ]; + const doomedHistory: ContextMessage[] = [ + ...fullHistory, + { + role: 'user', + content: [{ type: 'text', text: 'undone turn' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'undone answer' }], + toolCalls: [], + }, + ]; + const store = new TranscriptStore('s1'); + store.ensureAgent('main').apply([ + { + op: 'reset', + agentId: 'main', + snapshot: groupMessagesIntoSnapshot(doomedHistory), + }, + ]); + const ops: TranscriptOperation[] = []; + let invalidations = 0; + const binding = bindSessionTranscript( + store, + fakeSession(interactions, agents), + undefined, + (event) => ops.push(...event.ops), + () => { + invalidations += 1; + }, + async () => groupMessagesIntoSnapshot(fullHistory), + ); + main.bus.emit( + ev({ + type: 'task.started', + info: { + taskId: 'task-1', + kind: 'process', + description: 'build', + status: 'running', + detached: true, + startedAt: 1_700_000_000_000, + endedAt: null, + }, + }), + ); + + await main.reconcile('projection'); + main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + + const transcript = store.getAgent('main'); + const items = transcript?.getItems() ?? []; + const turns = items.filter((item): item is TranscriptTurn => item.kind === 'turn'); + expect(turns.map((turn) => turn.prompt)).toEqual([ + 'before compaction', + 'surviving turn', ]); + expect( + turns[0]?.steps.flatMap((step) => step.frames).find((frame) => frame.kind === 'tool'), + ).toMatchObject({ output: 'old tool result' }); + expect(items.filter((item) => item.kind === 'marker')).toHaveLength(1); + expect(items.some((item) => item.kind === 'turn' && item.prompt === 'undone turn')).toBe(false); + expect(transcript?.getTask('task-1')).toMatchObject({ state: 'running' }); + expect(ops.filter((op) => op.op === 'reset')).toHaveLength(1); + expect(invalidations).toBe(1); + binding.dispose(); + }); + + it('preserves surviving turn ids and world-time task refs across journal projection', async () => { + const interactions = new SessionInteractionService(); + const agents = new FakeAgents(); + const main = agents.add('main'); + const survivingHistory: ContextMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'kept prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'kept answer' }], + toolCalls: [], + }, + ]; + const store = new TranscriptStore('s1'); + const binding = bindSessionTranscript( + store, + fakeSession(interactions, agents), + undefined, + undefined, + undefined, + async () => + groupMessagesIntoSnapshot(survivingHistory, { + turnIds: [0, 0], + turns: [ + { turnId: 0, input: survivingHistory[0]!.content, origin: { kind: 'user' } }, + { turnId: 1, input: [], origin: { kind: 'retry' } }, + ], + }), + ); + const emitTurn = (turnId: number, origin: Record): void => { + main.bus.emit(ev({ type: 'turn.started', turnId, origin })); + main.bus.emit(ev({ type: 'turn.ended', turnId, reason: 'completed' })); + }; + + emitTurn(0, { kind: 'user' }); + emitTurn(1, { kind: 'retry' }); + emitTurn(2, { kind: 'user' }); + main.bus.emit( + ev({ + type: 'task.started', + info: { + taskId: 'task-world-time', + kind: 'process', + description: 'background build', + status: 'running', + detached: true, + startedAt: 1_700_000_000_000, + endedAt: null, + }, + }), + ); + + await main.reconcile('projection'); + main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + + const items = store.getAgent('main')?.getItems() ?? []; + expect( + items + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => turn.turnId), + ).toEqual(['t0', 't1']); + expect(items).toContainEqual( + expect.objectContaining({ kind: 'taskref', taskId: 'task-world-time' }), + ); + expect(store.getAgent('main')?.getTask('task-world-time')).toMatchObject({ + state: 'running', + }); binding.dispose(); }); it('projects session Todo changes into the main live transcript', () => { const todos = new FakeTodos(); + todos.setTodos([{ title: 'pre-bind', status: 'pending' }]); const store = new TranscriptStore('s1'); const binding = bindSessionTranscript( store, fakeSession(new SessionInteractionService(), undefined, todos), ); + expect(store.getAgent('main')?.getTodo('todo')?.items).toEqual([ + { title: 'pre-bind', status: 'pending' }, + ]); + todos.setTodos([{ title: 'kept', status: 'pending' }]); expect(store.getAgent('main')?.getTodo('todo')?.items).toEqual([ { title: 'kept', status: 'pending' }, @@ -1593,6 +1901,62 @@ describe('bindSessionTranscript', () => { } }); + it('drops a stale backfill that completes after a rewind reset', async () => { + const home = await seedWireHome(); + try { + const history: ContextMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'kept after rewind' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + ]; + const agents = new FakeAgents(); + const main = agents.add('main', { history }); + const service = new TranscriptService({ + homeDir: home, + core: fakeCoreWithAgents(new SessionInteractionService(), agents), + }); + let markStarted!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + markStarted = resolve; + }); + const blocked = new Promise((resolve) => { + release = resolve; + }); + vi.spyOn(service, 'readColdSnapshot').mockImplementation(async () => { + markStarted(); + await blocked; + return groupMessagesIntoSnapshot([ + { + role: 'user', + content: [{ type: 'text', text: 'stale before rewind' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + ]); + }); + + const store = service.forSessionLive('s1'); + await started; + main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + release(); + await service.whenReady('s1'); + + const prompts = store + ?.getAgent('main') + ?.getItems() + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => turn.prompt); + expect(prompts).toEqual(['kept after rewind']); + service.dropSession('s1'); + } finally { + await rm(home, { recursive: true, force: true }); + } + }); + it('re-asserts running when the backfill rebuilds the live turn completed', async () => { const home = await seedWireHome(); try { diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 54720a8279..3cbc59b86f 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -10,6 +10,7 @@ import type { IScopeHandle, Scope } from '@moonshot-ai/agent-core-v2'; import { ContextSizeModel, IAgentActivityView, + IAgentConversationReconciliationRegistry, IAgentContextSizeService, IAgentLifecycleService, IAgentProfileService, @@ -18,6 +19,7 @@ import { IEventService, ISessionInteractionService, ISessionLifecycleService, + ISessionTodoService, IWireService, ISessionMetadata, SessionInteractionService, @@ -78,6 +80,9 @@ class FakeAgentHandle { private readonly services = new Map(); constructor(readonly id: string) { this.services.set(IEventBus, this.bus); + this.services.set(IAgentConversationReconciliationRegistry, { + register: () => ({ dispose: () => {} }), + }); this.accessor = { get: (token: unknown) => this.services.get(token), }; @@ -191,6 +196,13 @@ function makeCore( if (t === ISessionInteractionService) return lifecycle.interactions; // Minimal metadata read for the transcript binding's descriptor pass. if (t === ISessionMetadata) return { read: async () => ({ agents: metaAgents }) }; + // Minimal todo facade for the transcript binding's change projection. + if (t === ISessionTodoService) { + return { + onDidChange: () => ({ dispose: () => {} }), + getTodos: () => [], + }; + } return undefined; }, }; diff --git a/packages/transcript/src/granularity/filterOps.ts b/packages/transcript/src/granularity/filterOps.ts index 4c1ca10860..3703caf83b 100644 --- a/packages/transcript/src/granularity/filterOps.ts +++ b/packages/transcript/src/granularity/filterOps.ts @@ -22,7 +22,16 @@ export function filterOpsForGrade( ): TranscriptOperation[] { const rank = GRADE_RANK[grade]; if (rank === 0) return []; - return ops.filter((op) => admits(grade, op)); + const filtered: TranscriptOperation[] = []; + for (const op of ops) { + if (!admits(grade, op)) continue; + filtered.push( + op.op === 'reset' + ? { ...op, snapshot: redactSnapshotForGrade(grade, op.snapshot) } + : op, + ); + } + return filtered; } function admits(grade: TranscriptGrade, op: TranscriptOperation): boolean { diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 4b4424fd8b..ca8817fa45 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -10,12 +10,9 @@ * - streamed-vs-persisted duplication is assumed already resolved upstream; * - interaction frames do not appear (approvals are not persisted as * context messages); - * - persisted messages carry no turn ids, so turn ordinals are assigned by - * grouping — **0-based, matching the engine's live turn numbering** — and - * can drift from the engine's ids when hidden origins (e.g. retries) make - * the engine consume an ordinal that grouping cannot see. Alignment is - * what makes a rebuilt slice safe to merge into a live store (backfill): - * the engine's next turn continues at `t` without colliding. + * - when the source supplies journal-recovered turn identity, it remains + * authoritative across hidden retries and same-turn steers; callers with + * only flat legacy messages use 0-based best-effort grouping. * * The input type is structural so the engine's `ContextMessage` is directly * assignable without a dependency from this package onto the engine. @@ -60,6 +57,17 @@ export interface HistoryMessage { readonly origin?: { readonly kind: string }; } +export interface HistoryTurnSeed { + readonly turnId: number; + readonly input: readonly HistoryContentPart[]; + readonly origin: { readonly kind: string }; +} + +export interface HistoryGroupingOptions { + readonly turnIds: readonly (number | undefined)[]; + readonly turns: readonly HistoryTurnSeed[]; +} + interface TurnDraft { turnId: string; ordinal: number; @@ -99,7 +107,11 @@ const FALLBACK_ORIGIN: TurnOrigin = { kind: 'other' }; export function groupMessagesIntoSnapshot( messages: readonly HistoryMessage[], + options?: HistoryGroupingOptions, ): AgentTranscriptSnapshot { + if (options !== undefined && options.turns.length > 0) { + return groupMessagesByStableTurn(messages, options); + } const items: TranscriptItem[] = []; const attachments: TranscriptAttachment[] = []; let turn: TurnDraft | undefined; @@ -256,8 +268,165 @@ export function groupMessagesIntoSnapshot( return { items, tasks: [], interactions: [], attachments, todos: [], meta: {} }; } +function groupMessagesByStableTurn( + messages: readonly HistoryMessage[], + options: HistoryGroupingOptions, +): AgentTranscriptSnapshot { + const attachments: TranscriptAttachment[] = []; + const drafts = new Map(); + const markers = new Map(); + let markerCount = 0; + let lastTurnId: number | undefined; + + const ensureDraft = (turnId: number, message?: HistoryMessage): TurnDraft => { + let draft = drafts.get(turnId); + if (draft !== undefined) return draft; + draft = { + turnId: `t${turnId}`, + ordinal: turnId, + origin: message === undefined ? FALLBACK_ORIGIN : mapOrigin(message), + prompt: message === undefined || isHiddenPrompt(message) ? undefined : textOf(message), + steps: [], + }; + drafts.set(turnId, draft); + return draft; + }; + + for (const seed of options.turns) { + const message: HistoryMessage = { + role: 'user', + content: seed.input, + origin: seed.origin, + }; + ensureDraft(seed.turnId, message); + } + + const collectAttachments = (message: HistoryMessage): string[] | undefined => { + const ids: string[] = []; + for (const part of message.content ?? []) { + if (part.type === 'image' || part.type === 'video' || part.type === 'audio') { + if (!('source' in part) || part.source === undefined) continue; + const source = part.source as HistoryMediaSource; + const entity: TranscriptAttachment = { + attachmentId: `att_${attachments.length + 1}`, + mediaType: source.kind === 'base64' ? source.media_type : `${part.type}/*`, + source: + source.kind === 'url' + ? { kind: 'url', url: source.url } + : source.kind === 'file' + ? { kind: 'file', fileId: source.file_id } + : undefined, + }; + attachments.push(entity); + ids.push(entity.attachmentId); + } else if (part.type === 'file' && 'file_id' in part) { + const entity: TranscriptAttachment = { + attachmentId: `att_${attachments.length + 1}`, + mediaType: part.media_type as string, + name: part.name as string, + size: part.size as number, + source: { kind: 'file', fileId: part.file_id as string }, + }; + attachments.push(entity); + ids.push(entity.attachmentId); + } + } + return ids.length > 0 ? ids : undefined; + }; + + for (const [index, message] of messages.entries()) { + if (message.role === 'system') continue; + const assignedTurnId = options.turnIds[index] ?? lastTurnId; + if (assignedTurnId !== undefined) lastTurnId = assignedTurnId; + const draft = assignedTurnId === undefined ? undefined : ensureDraft(assignedTurnId); + const originKind = message.origin?.kind; + + if (message.role === 'user') { + const markerKey = originKind === undefined ? undefined : MARKER_USER_ORIGINS[originKind]; + if (markerKey !== undefined) { + const list = markers.get(assignedTurnId) ?? []; + markerCount += 1; + list.push({ + kind: 'marker', + markerId: `m${markerCount}`, + marker: markerKey, + payload: { text: textOf(message), origin: message.origin }, + }); + markers.set(assignedTurnId, list); + } + if (draft !== undefined && draft.attachmentIds === undefined) { + draft.attachmentIds = collectAttachments(message); + } + continue; + } + + if (message.role === 'assistant' && draft !== undefined) { + const stepOrdinal = draft.steps.length + 1; + const step: StepDraft = { + stepId: `${draft.turnId}.${stepOrdinal}`, + ordinal: stepOrdinal, + frames: [], + }; + draft.steps.push(step); + let frameCount = 0; + const nextFrameId = (): string => `${step.stepId}.f${++frameCount}`; + for (const part of message.content ?? []) { + if (part.type === 'text' && 'text' in part && typeof part.text === 'string' && part.text.length > 0) { + step.frames.push({ kind: 'text', frameId: nextFrameId(), role: 'assistant', text: part.text }); + } else if (part.type === 'think' && 'think' in part && typeof part.think === 'string' && part.think.length > 0) { + step.frames.push({ kind: 'thinking', frameId: nextFrameId(), text: part.think }); + } + } + for (const call of message.toolCalls ?? []) { + step.frames.push({ + kind: 'tool', + frameId: `${step.stepId}.${call.id}`, + toolCallId: call.id, + name: call.name, + state: 'running', + input: parseArguments(call.arguments), + }); + } + continue; + } + + if (message.role === 'tool' && draft !== undefined) { + const frame = currentTurnToolFrame(draft, message.toolCallId); + if (frame?.kind !== 'tool') continue; + const output = textOf(message); + replaceToolFrame(draft, message.toolCallId!, { + ...frame, + state: message.isError ? 'error' : 'done', + output, + error: message.isError ? output : undefined, + }); + } + } + + const items: TranscriptItem[] = [...(markers.get(undefined) ?? [])]; + const emittedMarkerOrdinals = new Set(); + for (const draft of [...drafts.values()].toSorted((a, b) => a.ordinal - b.ordinal)) { + items.push(draftToTurnItem(draft), ...(markers.get(draft.ordinal) ?? [])); + emittedMarkerOrdinals.add(draft.ordinal); + } + for (const [ordinal, entries] of [...markers.entries()].toSorted(([a], [b]) => { + if (a === undefined) return -1; + if (b === undefined) return 1; + return a - b; + })) { + if (ordinal === undefined || emittedMarkerOrdinals.has(ordinal)) continue; + items.push(...entries); + } + return { items, tasks: [], interactions: [], attachments, todos: [], meta: {} }; +} + // ---------------------------------------------------------------- helpers +function isHiddenPrompt(message: HistoryMessage): boolean { + const kind = message.origin?.kind; + return kind !== undefined && HIDDEN_USER_ORIGINS.has(kind); +} + /** Whether a hidden-origin user message opened its own engine turn. */ function opensOwnTurn(message: HistoryMessage): boolean { const origin = message.origin as { kind?: unknown; name?: unknown } | undefined; diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 605845939f..548a378d7a 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -138,6 +138,15 @@ describe('granularity', () => { expect(turnGrade.items[1]?.kind).toBe('marker'); expect(redactSnapshotForGrade('block', snapshot)).toBe(snapshot); expect(redactSnapshotForGrade('delta', snapshot)).toBe(snapshot); + + const reset: TranscriptOperation = { op: 'reset', agentId: 'main', snapshot }; + const filteredReset = filterOpsForGrade('turn', [reset])[0]; + expect(filteredReset?.op).toBe('reset'); + expect( + filteredReset?.op === 'reset' && + filteredReset.snapshot.items[0]?.kind === 'turn' && + filteredReset.snapshot.items[0].steps, + ).toEqual([]); }); }); @@ -504,4 +513,61 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { if (taskTurn?.kind !== 'turn') throw new Error('expected turn'); expect(taskTurn.origin).toMatchObject({ kind: 'task', taskId: 'b83rhswvs' }); }); + + it('uses persisted turn ids so retry turns and same-turn steers do not renumber history', () => { + const messages = [ + { role: 'user', content: [{ type: 'text' as const, text: 'u0' }], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text' as const, text: 'a0' }] }, + { role: 'assistant', content: [{ type: 'text' as const, text: 'retry answer' }] }, + { role: 'user', content: [{ type: 'text' as const, text: 'u2' }], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text' as const, text: 'before steer' }] }, + ]; + const snapshot = groupMessagesIntoSnapshot(messages, { + turnIds: [0, 0, 1, 2, 2], + turns: [ + { turnId: 0, input: messages[0]!.content, origin: { kind: 'user' } }, + { turnId: 1, input: [], origin: { kind: 'retry' } }, + { turnId: 2, input: messages[3]!.content, origin: { kind: 'user' } }, + ], + }); + + const turns = snapshot.items.filter((item) => item.kind === 'turn'); + expect(turns.map((turn) => turn.turnId)).toEqual(['t0', 't1', 't2']); + expect(turns[1]?.prompt).toBeUndefined(); + expect(turns[1]?.steps[0]?.frames[0]).toMatchObject({ + kind: 'text', + text: 'retry answer', + }); + expect(turns[2]?.steps).toHaveLength(1); + expect(turns[2]?.steps[0]?.frames[0]).toMatchObject({ + kind: 'text', + text: 'before steer', + }); + }); + + it('projects a legacy idle steer as its own stable turn', () => { + const messages = [ + { role: 'user', content: [{ type: 'text' as const, text: 'u0' }], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text' as const, text: 'a0' }] }, + { role: 'user', content: [{ type: 'text' as const, text: 'u1' }], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text' as const, text: 'a1' }] }, + ]; + const snapshot = groupMessagesIntoSnapshot(messages, { + turnIds: [0, 0, 1, 1], + turns: [ + { turnId: 0, input: messages[0]!.content, origin: { kind: 'user' } }, + { turnId: 1, input: messages[2]!.content, origin: { kind: 'user' } }, + ], + }); + + const turns = snapshot.items.filter((item) => item.kind === 'turn'); + expect(turns.map((turn) => [turn.turnId, turn.prompt])).toEqual([ + ['t0', 'u0'], + ['t1', 'u1'], + ]); + expect(turns.map((turn) => turn.steps[0]?.frames[0])).toMatchObject([ + { kind: 'text', text: 'a0' }, + { kind: 'text', text: 'a1' }, + ]); + }); }); From 163044c644d631d5c5bd003aff39cf8a7188628b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 20:23:34 +0800 Subject: [PATCH 6/7] fix: make conversation undo durable and consistent --- apps/kimi-code/src/tui/commands/undo.ts | 14 +- .../test/tui/kimi-tui-message-flow.test.ts | 57 ++++- .../kimi-web/src/components/chat/ChatPane.vue | 10 +- .../src/components/chat/ConversationPane.vue | 6 +- packages/agent-core-v2/AGENTS.md | 2 +- .../scripts/check-domain-layers.mjs | 4 +- .../src/agent/contextMemory/contextOps.ts | 8 +- .../conversationReconciliation.ts | 66 ------ .../agent/contextMemory/conversationTime.ts | 8 +- .../conversationUndoReconciliation.ts | 66 ++++++ packages/agent-core-v2/src/agent/loop/loop.ts | 2 +- .../src/agent/loop/loopService.ts | 14 +- .../src/agent/plan/planService.ts | 2 +- .../src/agent/prompt/promptMetadataText.ts | 2 +- .../agent-core-v2/src/agent/rewind/rewind.ts | 23 -- .../agent-core-v2/src/agent/rpc/rpcService.ts | 7 +- .../src/agent/task/taskService.ts | 10 +- packages/agent-core-v2/src/agent/undo/undo.ts | 23 ++ .../rewindService.ts => undo/undoService.ts} | 85 +++++--- packages/agent-core-v2/src/index.ts | 6 +- .../src/session/todo/sessionTodoService.ts | 2 +- .../agent/contextMemory/undoPrecheck.test.ts | 11 +- .../test/agent/loop/loop.test.ts | 38 ++-- .../agent-core-v2/test/agent/loop/stubs.ts | 2 +- .../test/agent/plan/planOps.test.ts | 20 ++ .../test/agent/rpc/undoHistory.test.ts | 17 ++ .../test/agent/task/rpc-events.test.ts | 32 ++- .../test/agent/task/taskService.test.ts | 6 +- .../agent/toolSelect/toolSelect.e2e.test.ts | 4 +- .../toolSelect/toolSelectService.test.ts | 2 +- .../rewind.test.ts => undo/undo.test.ts} | 205 +++++++++++------- .../test/session/todo/sessionTodo.test.ts | 8 +- .../agent-core-v2/test/wire/resume.test.ts | 45 ++++ packages/kap-server/src/routes/sessions.ts | 8 +- .../src/services/transcript/coreBinding.ts | 51 +++-- .../src/services/transcript/coreEventMap.ts | 4 +- .../test/services/transcript.test.ts | 159 ++++++++++++-- .../test/sessionEventBroadcaster.test.ts | 4 +- packages/kap-server/test/sessions.test.ts | 4 +- 39 files changed, 710 insertions(+), 327 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts create mode 100644 packages/agent-core-v2/src/agent/contextMemory/conversationUndoReconciliation.ts delete mode 100644 packages/agent-core-v2/src/agent/rewind/rewind.ts create mode 100644 packages/agent-core-v2/src/agent/undo/undo.ts rename packages/agent-core-v2/src/agent/{rewind/rewindService.ts => undo/undoService.ts} (74%) rename packages/agent-core-v2/test/agent/{rewind/rewind.test.ts => undo/undo.test.ts} (68%) diff --git a/apps/kimi-code/src/tui/commands/undo.ts b/apps/kimi-code/src/tui/commands/undo.ts index 862a5b294b..8e32409989 100644 --- a/apps/kimi-code/src/tui/commands/undo.ts +++ b/apps/kimi-code/src/tui/commands/undo.ts @@ -375,16 +375,22 @@ function undoLimitFromError( ): (UndoAvailability & { readonly requestedCount: number }) | undefined { if (!isKimiError(error)) return undefined; const details = error.details; - // server-v2 rewind error shape: { reason, requestedCount, undoableCount } const reason = details?.['reason']; - if (reason !== 'empty' && reason !== 'compaction_boundary' && reason !== 'insufficient') { - return undefined; - } const requestedCount = details?.['requestedCount']; const maxCount = details?.['undoableCount']; if (typeof requestedCount !== 'number' || typeof maxCount !== 'number') { return undefined; } + + if (reason === 'undo_limit') { + const stoppedAtCompaction = details?.['stoppedAtCompaction']; + if (typeof stoppedAtCompaction !== 'boolean') return undefined; + return { requestedCount, maxCount, stoppedAtCompaction }; + } + + if (reason !== 'empty' && reason !== 'compaction_boundary' && reason !== 'insufficient') { + return undefined; + } return { requestedCount, maxCount, stoppedAtCompaction: reason === 'compaction_boundary' }; } 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 806bb43095..dd0e7b2e58 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 @@ -8,7 +8,13 @@ import { resetCapabilitiesCache, setCapabilities, } from '@moonshot-ai/pi-tui'; -import type { ApprovalRequest, ApprovalResponse, Event } from '@moonshot-ai/kimi-code-sdk'; +import { + ErrorCodes, + KimiError, + type ApprovalRequest, + type ApprovalResponse, + type Event, +} from '@moonshot-ai/kimi-code-sdk'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; @@ -1206,6 +1212,55 @@ command = "vim" expect(transcript).toContain('hello'); }); + it.each([ + { + backend: 'v1', + details: { + reason: 'undo_limit', + requestedCount: 1, + undoableCount: 0, + stoppedAtCompaction: true, + }, + }, + { + backend: 'v2', + details: { + reason: 'compaction_boundary', + requestedCount: 1, + undoableCount: 0, + }, + }, + ])('shows the undo limit from a $backend RPC error', async ({ details }) => { + const session = makeSession({ + undoHistory: vi.fn(async () => { + throw new KimiError(ErrorCodes.REQUEST_INVALID, 'Undo unavailable', { details }); + }), + }); + const { driver } = await makeDriver(session); + + driver.handleUserInput('hello'); + driver.state.appState.streamingPhase = 'idle'; + driver.handleUserInput('/undo 1'); + + await vi.waitFor(() => { + expect(stripSgr(renderTranscript(driver))).toContain( + 'Cannot undo 1 prompt; only 0 prompts can be undone in the active context after the last compaction.', + ); + }); + expect(stripSgr(renderTranscript(driver))).not.toContain('Error: Failed to undo'); + expect(driver.state.transcriptEntries).toEqual([ + expect.objectContaining({ + kind: 'user', + content: 'hello', + }), + expect.objectContaining({ + kind: 'status', + content: + 'Cannot undo 1 prompt; only 0 prompts can be undone in the active context after the last compaction.', + }), + ]); + }); + it('does not duplicate welcome after undoing the only turn', async () => { const { driver } = await makeDriver(); 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/packages/agent-core-v2/AGENTS.md b/packages/agent-core-v2/AGENTS.md index fbe52e9cda..9b6e84ea5b 100644 --- a/packages/agent-core-v2/AGENTS.md +++ b/packages/agent-core-v2/AGENTS.md @@ -55,7 +55,7 @@ Business code must not `import 'node:fs'`, write SQL, hand-roll append-logs / at ## 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 rewind pipeline's pre-cut depth check. World-time state (turn counters, task registries, revision counters) must stay outside checkpointed Models. +`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 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 8ac4aa6476..1ae6e900b8 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -214,11 +214,11 @@ const DOMAIN_LAYER = new Map([ ['sessionExport', 6], ['interaction', 6], ['sessionMetadata', 6], - // `rewind` owns the undo pipeline (quiesce → context.undo → reconcile): it + // `undo` owns the undo pipeline (quiesce → context.undo → reconcile): it // coordinates L4 agent domains (loop / prompt / contextMemory / // fullCompaction), L5 task delivery, and `sessionMetadata`, so it sits in // L6 beside the other cross-agent coordinators. - ['rewind', 6], + ['undo', 6], ['sessionActivity', 6], ['session', 6], ['terminal', 6], diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index e9981d0df6..b8c6a83b20 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -48,7 +48,7 @@ import { createCompactionSummaryMessage, type ContextCompactionShapeInput, } from './compactionHandoff'; -import { isUndoAnchor } from './conversationTime'; +import { isUndoAnchor, isValidUndoCount } from './conversationTime'; import { foldAppendMessage, foldLoopEvent, @@ -359,9 +359,11 @@ export function formatUndoUnavailableMessage( } 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[]; diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts deleted file mode 100644 index 3fbd7239d0..0000000000 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationReconciliation.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * `contextMemory` domain (L4) — Agent-scoped post-rewind reconciliation registry. - * - * Hosts state-repair and derived-projection participants for the rewind - * coordinator. Bound at Agent scope. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { createDecorator } from '#/_base/di/instantiation'; -import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; - -export interface AgentConversationReconciliationParticipant { - readonly id: string; - readonly phase?: AgentConversationReconciliationPhase; - reconcileAfterRewind(): Promise; -} - -export type AgentConversationReconciliationPhase = 'state' | 'projection'; - -export interface IAgentConversationReconciliationRegistry { - readonly _serviceBrand: undefined; - - register(participant: AgentConversationReconciliationParticipant): IDisposable; - list(): readonly AgentConversationReconciliationParticipant[]; -} - -export const IAgentConversationReconciliationRegistry = - createDecorator( - 'agentConversationReconciliationRegistry', - ); - -class AgentConversationReconciliationRegistry - extends Disposable - implements IAgentConversationReconciliationRegistry -{ - declare readonly _serviceBrand: undefined; - - private readonly participants = new Map(); - - register(participant: AgentConversationReconciliationParticipant): IDisposable { - if (this.participants.has(participant.id)) { - throw new Error( - `Conversation reconciliation 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 AgentConversationReconciliationParticipant[] { - return [...this.participants.values()]; - } -} - -registerScopedService( - LifecycleScope.Agent, - IAgentConversationReconciliationRegistry, - AgentConversationReconciliationRegistry, - InstantiationType.Eager, - 'conversationReconciliation', -); diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts index 6847056ad0..5314780eb7 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationTime.ts @@ -3,7 +3,7 @@ * wire-Model factory. * * Defines the undo anchor vocabulary and registers conversation-time Models - * for rewind validation. Scope-agnostic. + * for undo validation. Scope-agnostic. */ import { defineModel, type ModelDef } from '#/wire/model'; @@ -20,6 +20,10 @@ export function isUndoAnchor(message: ContextMessage): boolean { ); } +export function isValidUndoCount(count: number): boolean { + return Number.isSafeInteger(count) && count > 0; +} + export interface Checkpointed { readonly current: T; readonly checkpoints: readonly T[]; @@ -54,7 +58,7 @@ export function defineCheckpointedModel( 'context.clear': (state) => state.checkpoints.length === 0 ? state : { ...state, checkpoints: [] }, 'context.undo': (state, { count }) => { - if (count <= 0 || state.checkpoints.length < count) return state; + if (!isValidUndoCount(count) || state.checkpoints.length < count) return state; const checkpointIndex = state.checkpoints.length - count; return { current: state.checkpoints[checkpointIndex]!, diff --git a/packages/agent-core-v2/src/agent/contextMemory/conversationUndoReconciliation.ts b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoReconciliation.ts new file mode 100644 index 0000000000..0e9e2bf25b --- /dev/null +++ b/packages/agent-core-v2/src/agent/contextMemory/conversationUndoReconciliation.ts @@ -0,0 +1,66 @@ +/** + * `contextMemory` domain (L4) — Agent-scoped post-undo reconciliation registry. + * + * Hosts state-repair and derived-projection participants for the undo + * coordinator. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { createDecorator } from '#/_base/di/instantiation'; +import { Disposable, toDisposable, type IDisposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; + +export interface AgentConversationUndoReconciliationParticipant { + readonly id: string; + readonly phase?: AgentConversationUndoReconciliationPhase; + reconcileAfterUndo(): Promise; +} + +export type AgentConversationUndoReconciliationPhase = 'state' | 'projection'; + +export interface IAgentConversationUndoReconciliationRegistry { + readonly _serviceBrand: undefined; + + register(participant: AgentConversationUndoReconciliationParticipant): IDisposable; + list(): readonly AgentConversationUndoReconciliationParticipant[]; +} + +export const IAgentConversationUndoReconciliationRegistry = + createDecorator( + 'agentConversationUndoReconciliationRegistry', + ); + +class AgentConversationUndoReconciliationRegistry + extends Disposable + implements IAgentConversationUndoReconciliationRegistry +{ + declare readonly _serviceBrand: undefined; + + private readonly participants = new Map(); + + register(participant: AgentConversationUndoReconciliationParticipant): IDisposable { + if (this.participants.has(participant.id)) { + throw new Error( + `Conversation undo reconciliation 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 AgentConversationUndoReconciliationParticipant[] { + return [...this.participants.values()]; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentConversationUndoReconciliationRegistry, + AgentConversationUndoReconciliationRegistry, + InstantiationType.Eager, + 'conversationUndoReconciliation', +); diff --git a/packages/agent-core-v2/src/agent/loop/loop.ts b/packages/agent-core-v2/src/agent/loop/loop.ts index 74cbcdd630..241a1e25a9 100644 --- a/packages/agent-core-v2/src/agent/loop/loop.ts +++ b/packages/agent-core-v2/src/agent/loop/loop.ts @@ -146,7 +146,7 @@ export interface IAgentLoopService { cancel(turnId?: number, reason?: unknown): boolean; - acquireQuiescence(): Promise; + tryAcquireQuiescence(): IDisposable | undefined; /** Resolves once no turn is active and none are queued — the disposal drain * awaited by `agentLifecycle.remove`. */ diff --git a/packages/agent-core-v2/src/agent/loop/loopService.ts b/packages/agent-core-v2/src/agent/loop/loopService.ts index 5720408c80..c894edd505 100644 --- a/packages/agent-core-v2/src/agent/loop/loopService.ts +++ b/packages/agent-core-v2/src/agent/loop/loopService.ts @@ -105,7 +105,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { private activeTurnJob: TurnJob | undefined; private nextReservedTurnId: number | undefined; private readonly settleWaiters: Array<() => void> = []; - private readonly quiescenceWaiters: Array<() => void> = []; private quiescenceDepth = 0; private activeRequestTrace: LLMRequestTrace | undefined; private lastRequestTraceId: string | undefined; @@ -214,17 +213,10 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { ); } - async acquireQuiescence(): Promise { + tryAcquireQuiescence(): IDisposable | undefined { if (this.disposing) throw abortError('Agent loop disposed'); + if (this.activeTurnJob !== undefined || this.hasPendingRequests()) return undefined; this.quiescenceDepth += 1; - const active = this.activeTurnJob; - if (active !== undefined) { - this.cancel(active.turn.id); - await new Promise((resolve) => { - if (this.activeTurnJob === undefined) resolve(); - else this.quiescenceWaiters.push(resolve); - }); - } return toDisposable(() => this.releaseQuiescence()); } @@ -538,8 +530,6 @@ export class AgentLoopService extends Disposable implements IAgentLoopService { if (step.state === 'queued' || step.state === 'running') step.cancel(reason); } this.activeTurnJob = undefined; - const waiters = this.quiescenceWaiters.splice(0); - for (const resolve of waiters) resolve(); this.maybeSettle(); } diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index adc0011c77..7ca6ef3488 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -57,7 +57,7 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { }), ); this._register( - eventBus.subscribe('context.rewound', () => { + eventBus.subscribe('context.undone', () => { this.restoreTelemetryMode(); eventBus.publish({ type: 'agent.status.updated', diff --git a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts index 2d9c0ed5e7..e31a469823 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptMetadataText.ts @@ -1,7 +1,7 @@ /** * `prompt` domain (L4) — safe, displayable metadata text derived from prompts. * - * Shared by prompt submission and rewind projection so `lastPrompt` uses one + * Shared by prompt submission and undo projection so `lastPrompt` uses one * normalization, redaction, and length limit, with image captions supplied by * the `media` domain. */ diff --git a/packages/agent-core-v2/src/agent/rewind/rewind.ts b/packages/agent-core-v2/src/agent/rewind/rewind.ts deleted file mode 100644 index b34fa4a5e1..0000000000 --- a/packages/agent-core-v2/src/agent/rewind/rewind.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * `rewind` domain (L6) — Agent-scoped conversation undo contract. - * - * Defines the availability and execution surface shared by every undo entry - * point. Bound at Agent scope. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface RewindAvailability { - readonly maxTurns: number; - readonly stoppedAtCompaction: boolean; -} - -export interface IAgentRewindService { - readonly _serviceBrand: undefined; - - availability(): RewindAvailability; - rewind(turns: number): Promise; -} - -export const IAgentRewindService: ServiceIdentifier = - createDecorator('agentRewindService'); diff --git a/packages/agent-core-v2/src/agent/rpc/rpcService.ts b/packages/agent-core-v2/src/agent/rpc/rpcService.ts index 189c4190d2..58570e8a62 100644 --- a/packages/agent-core-v2/src/agent/rpc/rpcService.ts +++ b/packages/agent-core-v2/src/agent/rpc/rpcService.ts @@ -19,7 +19,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 { IAgentRewindService } from '#/agent/rewind/rewind'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IAgentSkillService } from '#/agent/skill/skill'; @@ -65,7 +65,8 @@ export class AgentRPCService implements IAgentRPCService { constructor( @IAgentPromptService private readonly promptService: IAgentPromptService, - @IAgentRewindService private readonly rewind: IAgentRewindService, + @IAgentConversationUndoService + private readonly conversationUndo: IAgentConversationUndoService, @IAgentLoopService private readonly loop: IAgentLoopService, @IAgentToolPolicyService private readonly toolPolicy: IAgentToolPolicyService, @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @@ -130,7 +131,7 @@ export class AgentRPCService implements IAgentRPCService { } async undoHistory(payload: UndoHistoryPayload): Promise { - return this.rewind.rewind(payload.count); + 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 2e48d3b6c1..b161bbab2d 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -26,7 +26,7 @@ import { import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; import { defineCheckpointedModel } from '#/agent/contextMemory/conversationTime'; -import { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; +import { IAgentConversationUndoReconciliationRegistry } from '#/agent/contextMemory/conversationUndoReconciliation'; import type { ContextMessage, TaskOrigin } from '#/agent/contextMemory/types'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -224,8 +224,8 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { @IEventBus private readonly eventBus: IEventBus, @IAgentContextInjectorService injector: IAgentContextInjectorService, @IAgentLoopService private readonly loop: IAgentLoopService, - @IAgentConversationReconciliationRegistry - conversationReconciliation: IAgentConversationReconciliationRegistry, + @IAgentConversationUndoReconciliationRegistry + conversationUndoReconciliation: IAgentConversationUndoReconciliationRegistry, @ILogService private readonly log: ILogService, ) { super(); @@ -241,9 +241,9 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { fallbackRoot, ); this._register( - conversationReconciliation.register({ + conversationUndoReconciliation.register({ id: 'task.notificationDelivery', - reconcileAfterRewind: () => this.reconcileNotificationDeliveryAfterUndo(), + reconcileAfterUndo: () => this.reconcileNotificationDeliveryAfterUndo(), }), ); this._register( 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/rewind/rewindService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts similarity index 74% rename from packages/agent-core-v2/src/agent/rewind/rewindService.ts rename to packages/agent-core-v2/src/agent/undo/undoService.ts index 4496ae65e3..1ce4923f22 100644 --- a/packages/agent-core-v2/src/agent/rewind/rewindService.ts +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -1,7 +1,7 @@ /** - * `rewind` domain (L6) — `IAgentRewindService` implementation. + * `undo` domain (L6) — `IAgentConversationUndoService` implementation. * - * Owns conversation rewind coordination and restored observable state. + * Owns idle conversation undo coordination and restored observable state. * Coordinates `contextMemory`, conversation reconciliation, `fullCompaction`, * `loop`, `prompt`, Agent and Session identity, `sessionMetadata`, `event`, * `eventBus`, `telemetry`, and `wire`. Bound at Agent scope. @@ -13,9 +13,9 @@ import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { ILogService } from '#/_base/log/log'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { - IAgentConversationReconciliationRegistry, - type AgentConversationReconciliationPhase, -} from '#/agent/contextMemory/conversationReconciliation'; + IAgentConversationUndoReconciliationRegistry, + type AgentConversationUndoReconciliationPhase, +} from '#/agent/contextMemory/conversationUndoReconciliation'; import { computeUndoCut, formatUndoUnavailableMessage, @@ -24,6 +24,7 @@ import { import { CHECKPOINTED_MODELS, isUndoAnchor, + isValidUndoCount, type Checkpointed, } from '#/agent/contextMemory/conversationTime'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; @@ -40,26 +41,29 @@ import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { IWireService } from '#/wire/wire'; -import { IAgentRewindService, type RewindAvailability } from './rewind'; +import { IAgentConversationUndoService, type UndoAvailability } from './undo'; declare module '#/app/event/eventBus' { interface DomainEventMap { - 'context.rewound': { turns: number }; + 'context.undone': { turns: number }; } } -export class AgentRewindService extends Disposable implements IAgentRewindService { +export class AgentConversationUndoService + extends Disposable + implements IAgentConversationUndoService +{ declare readonly _serviceBrand: undefined; - private rewindQueue: Promise = Promise.resolve(); + 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, - @IAgentConversationReconciliationRegistry - private readonly participants: IAgentConversationReconciliationRegistry, + @IAgentConversationUndoReconciliationRegistry + private readonly participants: IAgentConversationUndoReconciliationRegistry, @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, @ISessionContext private readonly session: ISessionContext, @ISessionMetadata private readonly metadata: ISessionMetadata, @@ -72,7 +76,7 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic super(); } - availability(): RewindAvailability { + availability(): UndoAvailability { const cut = computeUndoCut(this.context.get(), Number.MAX_SAFE_INTEGER); const maxTurns = Math.min(cut.removedCount, this.checkpointDepth()); return { @@ -81,27 +85,34 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic }; } - async rewind(turns: number): Promise { - if (turns <= 0) return 0; - this.assertUndoAvailable(turns); - const run = this.rewindQueue.then(() => this.rewindNow(turns)); - this.rewindQueue = run.then( + 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 rewindNow(turns: number): Promise { - this.assertUndoAvailable(turns); + private async undoNow(turns: number): Promise { const pause = this.prompt.pauseLaunching(); const compactionPause = this.fullCompaction.pauseLaunching(); let quiescence: IDisposable | undefined; try { - [quiescence] = await Promise.all([ - this.loop.acquireQuiescence(), - this.fullCompaction.cancel(), - ]); + 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'); @@ -111,7 +122,7 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic await this.flushAfterCommit('projection reconciliation'); await this.reconcileLastPromptSafely(); this.telemetry.track2('conversation_undo', { count: turns }); - this.eventBus.publish({ type: 'context.rewound', turns }); + this.eventBus.publish({ type: 'context.undone', turns }); return turns; } finally { compactionPause.dispose(); @@ -129,6 +140,13 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic return depth; } + 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) { @@ -164,16 +182,18 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic ); } - private async reconcileParticipants(phase: AgentConversationReconciliationPhase): Promise { + private async reconcileParticipants( + phase: AgentConversationUndoReconciliationPhase, + ): Promise { const participants = this.participants .list() .filter((participant) => (participant.phase ?? 'state') === phase); const results = await Promise.allSettled( - participants.map((participant) => participant.reconcileAfterRewind()), + participants.map((participant) => participant.reconcileAfterUndo()), ); results.forEach((result, index) => { if (result.status === 'fulfilled') return; - this.log.error('rewind participant reconciliation failed', { + this.log.error('undo participant reconciliation failed', { participantId: participants[index]?.id, error: result.reason, }); @@ -184,7 +204,7 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic try { await this.reconcileLastPrompt(); } catch (error) { - this.log.error('rewind lastPrompt reconciliation failed', { error }); + this.log.error('undo lastPrompt reconciliation failed', { error }); } } @@ -192,7 +212,8 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic try { await this.wire.flush(); } catch (error) { - this.log.error('rewind wire flush failed after in-memory commit', { stage, error }); + this.log.error('undo wire flush failed after in-memory commit', { stage, error }); + throw error; } } @@ -225,8 +246,8 @@ export class AgentRewindService extends Disposable implements IAgentRewindServic registerScopedService( LifecycleScope.Agent, - IAgentRewindService, - AgentRewindService, + IAgentConversationUndoService, + AgentConversationUndoService, InstantiationType.Eager, - 'rewind', + 'undo', ); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index fb3a3849e3..17ca327b43 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -386,7 +386,7 @@ export * from '#/agent/contextMemory/contextMemory'; export * from '#/agent/contextMemory/contextMemoryService'; export * from '#/agent/contextMemory/contextOps'; export * from '#/agent/contextMemory/compactionHandoff'; -export * from '#/agent/contextMemory/conversationReconciliation'; +export * from '#/agent/contextMemory/conversationUndoReconciliation'; export * from '#/agent/contextMemory/conversationTime'; export * from '#/agent/contextMemory/loopEventFold'; export * from '#/agent/contextMemory/messageId'; @@ -456,8 +456,8 @@ import '#/app/messageLegacy/errors'; export * from '#/app/messageLegacy/messageLegacy'; export * from '#/app/messageLegacy/messageLegacyService'; export * from '#/agent/replayBuilder/types'; -export * from '#/agent/rewind/rewind'; -export * from '#/agent/rewind/rewindService'; +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 dc952b35b8..e55cfb831b 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -103,7 +103,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic this.lastKnownTodos = handle.accessor.get(IWireService).getModel(TodoModel).current; this.trackAgentBinding( handle.id, - handle.accessor.get(IEventBus).subscribe('context.rewound', () => { + 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; 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 25aeac1f9f..acbe72909f 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/undoPrecheck.test.ts @@ -115,8 +115,11 @@ describe('contextUndo op', () => { expect(contextUndo.apply(state, { count: 1 })).toBe(state); }); - it('returns the same reference for a non-positive count', () => { - const state = [user(USER_ORIGIN), assistant()]; - expect(contextUndo.apply(state, { count: 0 })).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 de9150ef4a..2f8310bd62 100644 --- a/packages/agent-core-v2/test/agent/loop/loop.test.ts +++ b/packages/agent-core-v2/test/agent/loop/loop.test.ts @@ -529,50 +529,62 @@ describe('Agent loop', () => { expect(ctx.llmCalls).toHaveLength(3); }); - it('holds new admissions until the active turn is quiescent and the lease is released', async () => { + it('refuses a quiescence lease while a turn is active without cancelling it', async () => { let started!: () => void; const activeStarted = new Promise((resolve) => { started = resolve; }); - const hook = loop.hooks.onWillBeginStep.register('test-quiescence', async (hookCtx, next) => { + let release!: () => void; + const canFinish = new Promise((resolve) => { + release = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-quiescence', async (_hookCtx, next) => { started(); - await new Promise((_, reject) => { - hookCtx.signal.addEventListener('abort', () => reject(hookCtx.signal.reason), { once: true }); - }); + await canFinish; await next(); }); const active = (await loop.enqueue(nextTurnMessage('active')).assigned).turn; await activeStarted; - const leasePromise = loop.acquireQuiescence(); + + 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; }); - const lease = await leasePromise; - await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); await Promise.resolve(); expect(assigned).toBe(false); expect(loop.status()).toMatchObject({ state: 'idle', hasPendingRequests: true }); - hook.dispose(); - ctx.mockNextResponse({ type: 'text', text: 'after rewind' }); - lease.dispose(); + 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 = await loop.acquireQuiescence(); + 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(); + lease?.dispose(); expect(loop.status().state).toBe('idle'); }); diff --git a/packages/agent-core-v2/test/agent/loop/stubs.ts b/packages/agent-core-v2/test/agent/loop/stubs.ts index 619a036e65..9217c88fe3 100644 --- a/packages/agent-core-v2/test/agent/loop/stubs.ts +++ b/packages/agent-core-v2/test/agent/loop/stubs.ts @@ -69,7 +69,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; }, - acquireQuiescence: async () => toDisposable(() => {}), + 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/planOps.test.ts b/packages/agent-core-v2/test/agent/plan/planOps.test.ts index ea4fd70fe4..f1aa376854 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 { @@ -121,6 +122,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(); 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 cbb6a0714e..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, @@ -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/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index 7509783034..2ce7587320 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 @@ -26,7 +26,8 @@ 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 { IAgentRewindService } from '#/agent/rewind/rewind'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; +import { ErrorCodes } from '#/errors'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; import { configServices, @@ -737,7 +738,7 @@ describe('AgentTaskService — notification delivery', () => { new Error('output unavailable'), ); - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); expect(agent.context.appendUserMessage).toHaveBeenCalledTimes(2); expect(ctx.context.get().some((message) => message.origin?.kind === 'user')).toBe(false); @@ -749,7 +750,7 @@ describe('AgentTaskService — notification delivery', () => { } }); - it('re-delivers a queued notification cancelled by rewind before materialization', async () => { + it('preserves a queued notification when undo rejects an active turn', async () => { const fixture = createAgentTaskService(); const { ctx, manager } = fixture; const loop = ctx.get(IAgentLoopService); @@ -757,11 +758,13 @@ describe('AgentTaskService — notification delivery', () => { const started = new Promise((resolve) => { markStarted = resolve; }); - const hook = loop.hooks.onWillBeginStep.register('test-notification-rewind', async (hookCtx, next) => { + let release!: () => void; + const canFinish = new Promise((resolve) => { + release = resolve; + }); + const hook = loop.hooks.onWillBeginStep.register('test-notification-undo', async (_hookCtx, next) => { markStarted(); - await new Promise((_, reject) => { - hookCtx.signal.addEventListener('abort', () => reject(hookCtx.signal.reason), { once: true }); - }); + await canFinish; await next(); }); @@ -788,9 +791,19 @@ describe('AgentTaskService — notification delivery', () => { }); expect(notifiedCount(ctx)).toBe(0); - await ctx.get(IAgentRewindService).rewind(1); + 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([]); - await expect(active.result).resolves.toMatchObject({ type: 'cancelled' }); + 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([ @@ -800,6 +813,7 @@ describe('AgentTaskService — notification delivery', () => { ]); expect(notifiedCount(ctx)).toBe(1); } finally { + release(); hook.dispose(); await ctx.get(ISessionMetadata).ready; await ctx.dispose(); 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 062ce0584b..6c9a40d651 100644 --- a/packages/agent-core-v2/test/agent/task/taskService.test.ts +++ b/packages/agent-core-v2/test/agent/task/taskService.test.ts @@ -15,7 +15,7 @@ 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 { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; +import { IAgentConversationUndoReconciliationRegistry } from '#/agent/contextMemory/conversationUndoReconciliation'; import { IAgentContextInjectorService, type ContextInjectionContext, @@ -91,7 +91,7 @@ describe('AgentTaskService', () => { eventBus = disposables.add(new EventBusService()); injectionProviders = new Map(); ix.stub(ILogService, stubLog()); - ix.stub(IAgentConversationReconciliationRegistry, { + ix.stub(IAgentConversationUndoReconciliationRegistry, { register: () => toDisposable(() => {}), list: () => [], }); @@ -402,7 +402,7 @@ describe('AgentTaskService', () => { ): TestInstantiationService { const ix = disposables.add(new TestInstantiationService()); ix.stub(ILogService, stubLog()); - ix.stub(IAgentConversationReconciliationRegistry, { + ix.stub(IAgentConversationUndoReconciliationRegistry, { register: () => toDisposable(() => {}), list: () => [], }); 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 b53ded8f60..e287fc38bd 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,7 +21,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; -import { IAgentRewindService } from '#/agent/rewind/rewind'; +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'; @@ -186,7 +186,7 @@ describe('progressive tool disclosure end-to-end', () => { await ctx.rpc.prompt({ input: [{ type: 'text', text: 'load alpha' }] }); await ctx.untilTurnEnd(); - await ctx.get(IAgentRewindService).rewind(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 929bdb5fbc..b86471ef04 100644 --- a/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts +++ b/packages/agent-core-v2/test/agent/toolSelect/toolSelectService.test.ts @@ -223,7 +223,7 @@ class FakeLoopService implements IAgentLoopService { throw new Error('unused in this suite'); } - async acquireQuiescence(): Promise { + tryAcquireQuiescence(): IDisposable | undefined { return toDisposable(() => {}); } diff --git a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts similarity index 68% rename from packages/agent-core-v2/test/agent/rewind/rewind.test.ts rename to packages/agent-core-v2/test/agent/undo/undo.test.ts index aaeceaa224..50f86adc30 100644 --- a/packages/agent-core-v2/test/agent/rewind/rewind.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -1,14 +1,15 @@ /** - * Scenario: rewind validation and restoration across conversation-scoped models. - * Responsibility: AgentRewindService commits one undo and publishes restored observable state. + * 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/rewind/rewind.test.ts + * 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 { IAgentConversationReconciliationRegistry } from '#/agent/contextMemory/conversationReconciliation'; +import { IAgentConversationUndoReconciliationRegistry } from '#/agent/contextMemory/conversationUndoReconciliation'; import { contextApplyCompaction } from '#/agent/contextMemory/contextOps'; import { IAgentFullCompactionService } from '#/agent/fullCompaction/fullCompaction'; import { IAgentLoopService } from '#/agent/loop/loop'; @@ -17,7 +18,7 @@ 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 { IAgentRewindService } from '#/agent/rewind/rewind'; +import { IAgentConversationUndoService } from '#/agent/undo/undo'; import { IEventBus } from '#/app/event/eventBus'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; import { ErrorCodes } from '#/errors'; @@ -28,7 +29,7 @@ import { IWireService } from '#/wire/wire'; import { createTestAgent, telemetryServices, type TestAgentContext } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; -describe('AgentRewindService', () => { +describe('AgentConversationUndoService', () => { let ctx: TestAgentContext; let records: TelemetryRecord[]; @@ -49,31 +50,51 @@ describe('AgentRewindService', () => { it('exposes availability from context history', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); - expect(rewind.availability()).toEqual({ maxTurns: 0, stoppedAtCompaction: false }); + const undo = ctx.get(IAgentConversationUndoService); + expect(undo.availability()).toEqual({ maxTurns: 0, stoppedAtCompaction: false }); ctx.appendTurnExchange('u1', 'a1'); ctx.appendTurnExchange('u2', 'a2'); - expect(rewind.availability()).toEqual({ maxTurns: 2, stoppedAtCompaction: false }); + expect(undo.availability()).toEqual({ maxTurns: 2, stoppedAtCompaction: false }); }); it('rejects undo with structured reasons', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); + const undo = ctx.get(IAgentConversationUndoService); - await expect(rewind.rewind(1)).rejects.toMatchObject({ + 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(rewind.rewind(2)).rejects.toMatchObject({ + await expect(undo.undo(2)).rejects.toMatchObject({ code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, details: { reason: 'insufficient', requestedCount: 2, undoableCount: 1 }, }); }); - it('does not interrupt an active turn when undo is unavailable', async () => { + 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; @@ -84,7 +105,7 @@ describe('AgentRewindService', () => { const canFinish = new Promise((resolve) => { release = resolve; }); - const hook = loop.hooks.onWillBeginStep.register('test-invalid-rewind', async (_hookCtx, next) => { + const hook = loop.hooks.onWillBeginStep.register('test-invalid-undo', async (_hookCtx, next) => { started(); await canFinish; await next(); @@ -104,22 +125,53 @@ describe('AgentRewindService', () => { ).assigned ).turn; await didStart; + const history = ctx.context.get(); - await expect(ctx.get(IAgentRewindService).rewind(1)).rejects.toMatchObject({ - code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, - details: { reason: 'empty' }, + 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, + }); + const cancel = vi.spyOn(compaction, 'cancel').mockImplementation(async () => { + abortController.abort(); + }); + + 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 { + cancel.mockRestore(); + active.mockRestore(); + } + }); + it('refuses to cross a compaction boundary', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); + const undo = ctx.get(IAgentConversationUndoService); ctx.appendTurnExchange('u1', 'a1'); ctx.get(IAgentContextMemoryService).applyCompaction({ summary: 'summary of u1', @@ -129,13 +181,13 @@ describe('AgentRewindService', () => { }); ctx.appendTurnExchange('u2', 'a2'); - expect(rewind.availability()).toEqual({ maxTurns: 1, stoppedAtCompaction: true }); - await expect(rewind.rewind(2)).rejects.toMatchObject({ + 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 rewind.rewind(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'); @@ -143,14 +195,14 @@ describe('AgentRewindService', () => { it('refuses loudly when a legacy compaction leaves anchors without checkpoints', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); + 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(rewind.rewind(1)).rejects.toMatchObject({ + await expect(undo.undo(1)).rejects.toMatchObject({ code: ErrorCodes.SESSION_UNDO_UNAVAILABLE, details: { reason: 'compaction_boundary', requestedCount: 1, undoableCount: 0 }, }); @@ -159,21 +211,21 @@ describe('AgentRewindService', () => { it('restores todos to their pre-turn value', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); + 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 rewind.rewind(1); + 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 rewind = ctx.get(IAgentRewindService); + const undo = ctx.get(IAgentConversationUndoService); const wire = ctx.get(IWireService); ctx.appendTurnExchange('u1', 'a1'); ctx.appendTurnExchange('u2', 'a2'); @@ -184,7 +236,7 @@ describe('AgentRewindService', () => { }); try { - await rewind.rewind(1); + await undo.undo(1); expect(wire.getModel(PlanModel).current.active).toBe(false); expect(ctx.get(IAgentTelemetryContextService).get().mode).toBe('agent'); @@ -196,13 +248,13 @@ describe('AgentRewindService', () => { it('does not roll back world-time turn bookkeeping', async () => { setup(); - const rewind = ctx.get(IAgentRewindService); + 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 rewind.rewind(1); + await undo.undo(1); expect(wire.getModel(TurnModel).nextTurnId).toBe(2); }); @@ -217,27 +269,27 @@ describe('AgentRewindService', () => { order.push('flush'); await originalFlush?.(); }); - const participants = ctx.get(IAgentConversationReconciliationRegistry); + const participants = ctx.get(IAgentConversationUndoReconciliationRegistry); participants.register({ id: 'test.state', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { order.push('state'); }, }); participants.register({ id: 'test.projection', phase: 'projection', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { order.push('projection'); }, }); - const subscription = ctx.get(IEventBus).subscribe('context.rewound', () => { - order.push('context.rewound'); + const subscription = ctx.get(IEventBus).subscribe('context.undone', () => { + order.push('context.undone'); }); ctx.appendTurnExchange('u1', 'a1'); try { - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); expect(order).toEqual([ 'flush', @@ -245,7 +297,7 @@ describe('AgentRewindService', () => { 'flush', 'projection', 'flush', - 'context.rewound', + 'context.undone', ]); } finally { subscription.dispose(); @@ -253,44 +305,50 @@ describe('AgentRewindService', () => { } }); - it.each([1, 2, 3])( - 'finishes the committed rewind when post-cut flush %i fails', - async (failureCall) => { + it.each([ + [1, []], + [2, ['state']], + [3, ['state', 'projection']], + ] 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 new Error('storage unavailable'); + if (flushCalls === failureCall) throw storageError; await originalFlush(); }); const reconciled: string[] = []; - const participants = ctx.get(IAgentConversationReconciliationRegistry); + const participants = ctx.get(IAgentConversationUndoReconciliationRegistry); participants.register({ id: 'test.flush-failure-state', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { reconciled.push('state'); }, }); participants.register({ id: 'test.flush-failure-projection', phase: 'projection', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { reconciled.push('projection'); }, }); - const rewound: number[] = []; - const subscription = ctx.get(IEventBus).subscribe('context.rewound', ({ turns }) => { - rewound.push(turns); + const undone: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.undone', ({ turns }) => { + undone.push(turns); }); ctx.appendTurnExchange('u1', 'a1'); try { - await expect(ctx.get(IAgentRewindService).rewind(1)).resolves.toBe(1); + await expect(ctx.get(IAgentConversationUndoService).undo(1)).rejects.toBe(storageError); expect(ctx.context.get()).toEqual([]); - expect(reconciled).toEqual(['state', 'projection']); - expect(rewound).toEqual([1]); + expect(reconciled).toEqual(expectedReconciled); + expect(undone).toEqual([]); + expect(records.filter((record) => record.event === 'conversation_undo')).toEqual([]); } finally { subscription.dispose(); flush.mockRestore(); @@ -298,19 +356,19 @@ describe('AgentRewindService', () => { }, ); - it('prevents compaction from starting until rewind reconciliation finishes', async () => { + it('prevents compaction from starting until undo reconciliation finishes', async () => { setup(); const compaction = ctx.get(IAgentFullCompactionService); let beginAccepted: boolean | undefined; - ctx.get(IAgentConversationReconciliationRegistry).register({ + ctx.get(IAgentConversationUndoReconciliationRegistry).register({ id: 'test.compaction-admission', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { beginAccepted = compaction.begin({ source: 'manual' }); }, }); ctx.appendTurnExchange('u1', 'a1'); - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); expect(beginAccepted).toBe(false); expect(() => compaction.begin({ source: 'manual' })).toThrowError( @@ -333,9 +391,10 @@ describe('AgentRewindService', () => { }; }); const loop = ctx.get(IAgentLoopService); - const originalAcquireQuiescence = loop.acquireQuiescence.bind(loop); - const acquireQuiescence = vi.spyOn(loop, 'acquireQuiescence').mockImplementation(async () => { - const lease = await originalAcquireQuiescence(); + const originalTryAcquireQuiescence = loop.tryAcquireQuiescence.bind(loop); + const tryAcquireQuiescence = vi.spyOn(loop, 'tryAcquireQuiescence').mockImplementation(() => { + const lease = originalTryAcquireQuiescence(); + if (lease === undefined) return undefined; return { dispose: () => { order.push('loop'); @@ -346,15 +405,15 @@ describe('AgentRewindService', () => { ctx.appendTurnExchange('u1', 'a1'); try { - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); expect(order).toEqual(['compaction', 'loop']); } finally { - acquireQuiescence.mockRestore(); + tryAcquireQuiescence.mockRestore(); pauseLaunching.mockRestore(); } }); - it('serializes concurrent rewinds through projection reconciliation', async () => { + it('serializes concurrent undos through projection reconciliation', async () => { setup(); ctx.appendTurnExchange('u1', 'a1'); ctx.appendTurnExchange('u2', 'a2'); @@ -369,10 +428,10 @@ describe('AgentRewindService', () => { let calls = 0; let active = 0; let maxActive = 0; - ctx.get(IAgentConversationReconciliationRegistry).register({ + ctx.get(IAgentConversationUndoReconciliationRegistry).register({ id: 'test.serial-projection', phase: 'projection', - reconcileAfterRewind: async () => { + reconcileAfterUndo: async () => { calls += 1; active += 1; maxActive = Math.max(maxActive, active); @@ -384,9 +443,9 @@ describe('AgentRewindService', () => { }, }); - const first = ctx.get(IAgentRewindService).rewind(1); + const first = ctx.get(IAgentConversationUndoService).undo(1); await firstStarted; - const second = ctx.get(IAgentRewindService).rewind(1); + const second = ctx.get(IAgentConversationUndoService).undo(1); await Promise.resolve(); expect(calls).toBe(1); @@ -399,9 +458,9 @@ describe('AgentRewindService', () => { expect(ctx.context.get()).toEqual([]); }); - it('publishes context.rewound and tracks conversation_undo', async () => { + it('publishes context.undone and tracks conversation_undo', async () => { setup(); - ctx.get(IAgentRewindService); + ctx.get(IAgentConversationUndoService); ctx.appendTurnExchange('u1', 'a1'); ctx.appendTurnExchange('u2', 'a2'); @@ -421,7 +480,7 @@ describe('AgentRewindService', () => { await metadata.update({ lastPrompt: 'u1' }); ctx.appendTurnExchange('u1', 'a1'); - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); await expect(metadata.read()).resolves.toMatchObject({ lastPrompt: undefined }); }); @@ -451,7 +510,7 @@ describe('AgentRewindService', () => { }); try { - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); await expect(metadata.read()).resolves.toMatchObject({ lastPrompt: 'queued prompt' }); } finally { list.mockRestore(); @@ -465,16 +524,16 @@ describe('AgentRewindService', () => { const update = vi.spyOn(ctx.get(ISessionMetadata), 'update').mockRejectedValueOnce( new Error('metadata write failed'), ); - const rewound: number[] = []; - const subscription = ctx.get(IEventBus).subscribe('context.rewound', ({ turns }) => { - rewound.push(turns); + const undone: number[] = []; + const subscription = ctx.get(IEventBus).subscribe('context.undone', ({ turns }) => { + undone.push(turns); }); try { - await expect(ctx.get(IAgentRewindService).rewind(1)).resolves.toBe(1); + await expect(ctx.get(IAgentConversationUndoService).undo(1)).resolves.toBe(1); expect(ctx.context.get().map((message) => message.role)).toEqual(['user', 'assistant']); - expect(rewound).toEqual([1]); + expect(undone).toEqual([1]); expect(records).toContainEqual({ event: 'conversation_undo', properties: { agent_id: 'main', count: 1 }, @@ -489,7 +548,7 @@ describe('AgentRewindService', () => { setup(); ctx.appendTurnExchange('u1', 'a1'); - await ctx.get(IAgentRewindService).rewind(1); + await ctx.get(IAgentConversationUndoService).undo(1); await ctx.get(IWireService).flush(); const wireEvents = ctx.allEvents 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 c4274e7fca..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,5 +1,5 @@ /** - * Scenario: session-shared Todo state, including rewind restoration. + * 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 @@ -220,7 +220,7 @@ describe('SessionTodoService', () => { ]); }); - it('fires the restored list once when rewind changes the main wire state', async () => { + 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); @@ -231,8 +231,8 @@ describe('SessionTodoService', () => { await main.restore([ { type: 'tools.update_store', key: 'todo', value: [{ title: 'kept', status: 'pending' }] }, ]); - main.eventBus.publish({ type: 'context.rewound', turns: 1 }); - main.eventBus.publish({ type: 'context.rewound', turns: 1 }); + 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' }]]); diff --git a/packages/agent-core-v2/test/wire/resume.test.ts b/packages/agent-core-v2/test/wire/resume.test.ts index 8891649afb..4d177f9b93 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, @@ -813,6 +817,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 61d99b09bd..d635ff6bed 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 `IAgentRewindService.rewind` directly (it 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` @@ -75,7 +75,7 @@ import { ErrorCodes, IAgentContextMemoryService, IAgentProfileService, - IAgentRewindService, + IAgentConversationUndoService, IAgentFullCompactionService, IAgentLifecycleService, IAgentRPCService, @@ -689,11 +689,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); - // `rewind.rewind` throws `session.undo_unavailable` (with a + // 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(IAgentRewindService).rewind(body.count); + 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([ diff --git a/packages/kap-server/src/services/transcript/coreBinding.ts b/packages/kap-server/src/services/transcript/coreBinding.ts index c0ad862646..2f0c6f07f0 100644 --- a/packages/kap-server/src/services/transcript/coreBinding.ts +++ b/packages/kap-server/src/services/transcript/coreBinding.ts @@ -21,7 +21,7 @@ import { IAgentLifecycleService, IAgentActivityView, - IAgentConversationReconciliationRegistry, + IAgentConversationUndoReconciliationRegistry, IAgentContextMemoryService, IEventBus, ISessionMetadata, @@ -82,8 +82,8 @@ export function bindSessionTranscript( * state-style/idempotent, so replaying a locally-unchanged upsert is safe. */ onOps?: (event: TranscriptChangeEvent) => void, - onRewind?: (agentId: string) => void, - loadRewoundSnapshot?: (agentId: string) => Promise, + onUndo?: (agentId: string) => void, + loadPostUndoSnapshot?: (agentId: string) => Promise, ): TranscriptBinding { const agents = session.accessor.get(IAgentLifecycleService); const interactions = session.accessor.get(ISessionInteractionService); @@ -107,8 +107,8 @@ export function bindSessionTranscript( const earlyResolves = new Map(); /** Agents whose pendings may announce live (their backfill+seed has run). */ const seededAgents = new Set(); - /** Projection outcomes waiting for their matching `context.rewound` event. */ - const rewoundProjections = new Map(); + /** Projection outcomes waiting for their matching `context.undone` event. */ + const undoProjections = new Map(); let seededAll = false; const isSeeded = (agentId: string): boolean => seededAll || seededAgents.has(agentId); @@ -181,17 +181,17 @@ export function bindSessionTranscript( // tracked per agent and disposed with it — the listener captures the // projector, so a dead agent must not keep projecting into the store. const list = agentDisposables.get(handle.id) ?? []; - if (loadRewoundSnapshot !== undefined) { - const reconciliation = handle.accessor.get(IAgentConversationReconciliationRegistry); + if (loadPostUndoSnapshot !== undefined) { + const reconciliation = handle.accessor.get(IAgentConversationUndoReconciliationRegistry); list.push( reconciliation.register({ id: 'transcript.projection', phase: 'projection', - reconcileAfterRewind: async () => { - onRewind?.(handle.id); + reconcileAfterUndo: async () => { + onUndo?.(handle.id); let projected = false; try { - const rebuilt = await loadRewoundSnapshot(handle.id); + const rebuilt = await loadPostUndoSnapshot(handle.id); if (rebuilt !== undefined) { const transcript = store.ensureAgent(handle.id); applyOps(handle.id, [ @@ -210,12 +210,12 @@ export function bindSessionTranscript( agentId: handle.id, err: error instanceof Error ? error.message : error, }, - 'transcript: post-rewind projection failed, falling back to live context', + 'transcript: post-undo projection failed, falling back to live context', ); } finally { - const outcomes = rewoundProjections.get(handle.id) ?? []; + const outcomes = undoProjections.get(handle.id) ?? []; outcomes.push(projected); - rewoundProjections.set(handle.id, outcomes); + undoProjections.set(handle.id, outcomes); } }, }), @@ -223,12 +223,12 @@ export function bindSessionTranscript( } const bus = handle.accessor.get(IEventBus); const busD = bus.subscribe((event) => { - if (event.type === 'context.rewound') { - const outcomes = rewoundProjections.get(handle.id); + if (event.type === 'context.undone') { + const outcomes = undoProjections.get(handle.id); const projected = outcomes?.shift(); - if (outcomes?.length === 0) rewoundProjections.delete(handle.id); + if (outcomes?.length === 0) undoProjections.delete(handle.id); if (projected === true) return; - if (projected === undefined) onRewind?.(handle.id); + if (projected === undefined) onUndo?.(handle.id); const transcript = store.ensureAgent(handle.id); const rebuilt = groupMessagesIntoSnapshot( handle.accessor.get(IAgentContextMemoryService).get(), @@ -311,7 +311,7 @@ export function bindSessionTranscript( agentDisposables.delete(agentId); subscribedAgents.delete(agentId); projectors.delete(agentId); - rewoundProjections.delete(agentId); + undoProjections.delete(agentId); store.markDisposed(agentId, new Date().toISOString()); }), ); @@ -419,7 +419,7 @@ export function bindSessionTranscript( knownInteractions.clear(); unseeded.clear(); earlyResolves.clear(); - rewoundProjections.clear(); + undoProjections.clear(); }, }; } @@ -428,6 +428,15 @@ function conversationResetSnapshot( rebuilt: AgentTranscriptSnapshot, current: AgentTranscriptSnapshot, ): AgentTranscriptSnapshot { + const survivingToolCallIds = new Set(); + for (const item of rebuilt.items) { + if (item.kind !== 'turn') continue; + for (const step of item.steps) { + for (const frame of step.frames) { + if (frame.kind === 'tool') survivingToolCallIds.add(frame.toolCallId); + } + } + } const attachments = new Map( rebuilt.attachments.map((attachment) => [attachment.attachmentId, attachment]), ); @@ -438,7 +447,9 @@ function conversationResetSnapshot( ...rebuilt, items: preserveTaskRefs(rebuilt.items, current.items), tasks: current.tasks, - interactions: current.interactions, + interactions: current.interactions.filter((interaction) => + survivingToolCallIds.has(interaction.toolCallId), + ), attachments: [...attachments.values()], todos: current.todos, meta: current.meta, diff --git a/packages/kap-server/src/services/transcript/coreEventMap.ts b/packages/kap-server/src/services/transcript/coreEventMap.ts index e2955b8cd9..c29fd94efd 100644 --- a/packages/kap-server/src/services/transcript/coreEventMap.ts +++ b/packages/kap-server/src/services/transcript/coreEventMap.ts @@ -15,7 +15,7 @@ * progress streaming are dropped; `tool.result` carries the terminal * state). Known limitation. * - `context.spliced` projects the visible undo marker. The session binding - * handles `context.rewound` as a full conversation projection reset. + * handles `context.undone` as a full conversation projection reset. * - `error` / `warning` become `marker.upsert{ marker: 'notice' }` and never * enter a step. * - No `swarm.*` / `plan.*` mode-transition events exist on the v2 bus today; @@ -201,7 +201,7 @@ export class AgentTranscriptProjector { ]; case 'context.spliced': return [this.markerOp('undo', restOf(event))]; - case 'context.rewound': + case 'context.undone': return []; case 'error': return [this.noticeOp('error', event.message, restOf(event))]; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 2d6e420251..341bcf74a3 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -12,7 +12,7 @@ import { join } from 'node:path'; import { IAgentLifecycleService, - IAgentConversationReconciliationRegistry, + IAgentConversationUndoReconciliationRegistry, IAgentContextMemoryService, IAgentLoopService, IEventBus, @@ -697,7 +697,7 @@ describe('AgentTranscriptProjector', () => { feed(ev({ type: 'compaction.started', trigger: 'auto' })); feed(ev({ type: 'compaction.completed', result: { kept: 3 } })); feed(ev({ type: 'context.spliced', start: 1, deleteCount: 2, messages: [] })); - feed(ev({ type: 'context.rewound', turns: 1 })); + feed(ev({ type: 'context.undone', turns: 1 })); const markers = tx .getItems() @@ -1025,13 +1025,13 @@ describe('bindSessionTranscript', () => { string, { readonly phase?: 'state' | 'projection'; - reconcileAfterRewind(): Promise; + reconcileAfterUndo(): Promise; } >(); register(participant: { readonly id: string; readonly phase?: 'state' | 'projection'; - reconcileAfterRewind(): Promise; + reconcileAfterUndo(): Promise; }): { dispose: () => void } { this.participants.set(participant.id, participant); return { @@ -1046,7 +1046,7 @@ describe('bindSessionTranscript', () => { await Promise.all( [...this.participants.values()] .filter((participant) => (participant.phase ?? 'state') === phase) - .map((participant) => participant.reconcileAfterRewind()), + .map((participant) => participant.reconcileAfterUndo()), ); } } @@ -1082,7 +1082,7 @@ describe('bindSessionTranscript', () => { accessor: { get: (token: unknown) => { if (token === IEventBus) return bus; - if (token === IAgentConversationReconciliationRegistry) return reconciliation; + if (token === IAgentConversationUndoReconciliationRegistry) return reconciliation; if (token === IAgentLoopService) { return { status: () => opts?.loopStatus ?? { state: 'idle' } }; } @@ -1150,7 +1150,7 @@ describe('bindSessionTranscript', () => { } as unknown as ISessionScopeHandle; } - it('resets conversation items from the authoritative post-rewind context', () => { + it('resets conversation items from the authoritative post-undo context', () => { const interactions = new SessionInteractionService(); const agents = new FakeAgents(); const main = agents.add('main', { @@ -1212,7 +1212,7 @@ describe('bindSessionTranscript', () => { }), ); main.bus.emit(ev({ type: 'context.spliced', start: 1, deleteCount: 4, messages: [] })); - main.bus.emit(ev({ type: 'context.rewound', turns: 2 })); + main.bus.emit(ev({ type: 'context.undone', turns: 2 })); expect( store @@ -1228,7 +1228,7 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); - it('does not infer the rewind cut from the displayed turn count', () => { + it('does not infer the undo cut from the displayed turn count', () => { const interactions = new SessionInteractionService(); const agents = new FakeAgents(); const main = agents.add('main', { @@ -1265,7 +1265,7 @@ describe('bindSessionTranscript', () => { main.bus.emit(ev({ type: 'turn.ended', turnId: 1, reason: 'completed' })); main.bus.emit(ev({ type: 'context.spliced', start: 0, deleteCount: 3, messages: [] })); - main.bus.emit(ev({ type: 'context.rewound', turns: 5 })); + main.bus.emit(ev({ type: 'context.undone', turns: 5 })); expect( store @@ -1279,7 +1279,7 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); - it('rebuilds the post-rewind projection from full journal history after compaction', async () => { + it('rebuilds the post-undo projection from full journal history after compaction', async () => { const interactions = new SessionInteractionService(); const agents = new FakeAgents(); const foldedHistory: ContextMessage[] = [ @@ -1385,7 +1385,7 @@ describe('bindSessionTranscript', () => { ); await main.reconcile('projection'); - main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + main.bus.emit(ev({ type: 'context.undone', turns: 1 })); const transcript = store.getAgent('main'); const items = transcript?.getItems() ?? []; @@ -1405,6 +1405,129 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); + it('keeps only interactions anchored to surviving tool calls after undo', async () => { + const interactions = new SessionInteractionService(); + const agents = new FakeAgents(); + const main = agents.add('main'); + const survivingHistory: ContextMessage[] = [ + { + role: 'user', + content: [{ type: 'text', text: 'kept prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call-kept', + name: 'Read', + arguments: '{"path":"/kept"}', + }, + ], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'kept result' }], + toolCalls: [], + toolCallId: 'call-kept', + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'kept answer' }], + toolCalls: [], + }, + ]; + const doomedHistory: ContextMessage[] = [ + ...survivingHistory, + { + role: 'user', + content: [{ type: 'text', text: 'undone prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call-undone-resolved', + name: 'Write', + arguments: '{"path":"/undone"}', + }, + { + type: 'function', + id: 'call-undone-pending', + name: 'Bash', + arguments: '{"command":"false"}', + }, + ], + }, + ]; + const store = new TranscriptStore('s1'); + store.ensureAgent('main').apply([ + { + op: 'reset', + agentId: 'main', + snapshot: groupMessagesIntoSnapshot(doomedHistory), + }, + { + op: 'interaction.upsert', + interaction: { + interactionId: 'approval-kept', + interactionKind: 'approval', + toolCallId: 'call-kept', + state: 'approved', + response: { decision: 'approved' }, + }, + }, + { + op: 'interaction.upsert', + interaction: { + interactionId: 'approval-undone', + interactionKind: 'approval', + toolCallId: 'call-undone-resolved', + state: 'approved', + response: { decision: 'approved' }, + }, + }, + { + op: 'interaction.upsert', + interaction: { + interactionId: 'question-undone', + interactionKind: 'question', + toolCallId: 'call-undone-pending', + state: 'pending', + request: { questions: [{ question: 'Continue?', options: [] }] }, + }, + }, + ]); + const binding = bindSessionTranscript( + store, + fakeSession(interactions, agents), + undefined, + undefined, + undefined, + async () => groupMessagesIntoSnapshot(survivingHistory), + ); + + await main.reconcile('projection'); + main.bus.emit(ev({ type: 'context.undone', turns: 1 })); + + const transcript = store.getAgent('main'); + expect([...transcript!.getInteractions().keys()]).toEqual(['approval-kept']); + expect(transcript?.getInteraction('approval-kept')).toMatchObject({ + toolCallId: 'call-kept', + state: 'approved', + }); + expect(transcript?.getInteraction('approval-undone')).toBeUndefined(); + expect(transcript?.listPendingInteractions()).toEqual([]); + binding.dispose(); + }); + it('preserves surviving turn ids and world-time task refs across journal projection', async () => { const interactions = new SessionInteractionService(); const agents = new FakeAgents(); @@ -1462,7 +1585,7 @@ describe('bindSessionTranscript', () => { ); await main.reconcile('projection'); - main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + main.bus.emit(ev({ type: 'context.undone', turns: 1 })); const items = store.getAgent('main')?.getItems() ?? []; expect( @@ -1901,13 +2024,13 @@ describe('bindSessionTranscript', () => { } }); - it('drops a stale backfill that completes after a rewind reset', async () => { + it('drops a stale backfill that completes after an undo reset', async () => { const home = await seedWireHome(); try { const history: ContextMessage[] = [ { role: 'user', - content: [{ type: 'text', text: 'kept after rewind' }], + content: [{ type: 'text', text: 'kept after undo' }], toolCalls: [], origin: { kind: 'user' }, }, @@ -1932,7 +2055,7 @@ describe('bindSessionTranscript', () => { return groupMessagesIntoSnapshot([ { role: 'user', - content: [{ type: 'text', text: 'stale before rewind' }], + content: [{ type: 'text', text: 'stale before undo' }], toolCalls: [], origin: { kind: 'user' }, }, @@ -1941,7 +2064,7 @@ describe('bindSessionTranscript', () => { const store = service.forSessionLive('s1'); await started; - main.bus.emit(ev({ type: 'context.rewound', turns: 1 })); + main.bus.emit(ev({ type: 'context.undone', turns: 1 })); release(); await service.whenReady('s1'); @@ -1950,7 +2073,7 @@ describe('bindSessionTranscript', () => { ?.getItems() .filter((item): item is TranscriptTurn => item.kind === 'turn') .map((turn) => turn.prompt); - expect(prompts).toEqual(['kept after rewind']); + expect(prompts).toEqual(['kept after undo']); service.dropSession('s1'); } finally { await rm(home, { recursive: true, force: true }); diff --git a/packages/kap-server/test/sessionEventBroadcaster.test.ts b/packages/kap-server/test/sessionEventBroadcaster.test.ts index 3cbc59b86f..4c9b3682f5 100644 --- a/packages/kap-server/test/sessionEventBroadcaster.test.ts +++ b/packages/kap-server/test/sessionEventBroadcaster.test.ts @@ -10,7 +10,7 @@ import type { IScopeHandle, Scope } from '@moonshot-ai/agent-core-v2'; import { ContextSizeModel, IAgentActivityView, - IAgentConversationReconciliationRegistry, + IAgentConversationUndoReconciliationRegistry, IAgentContextSizeService, IAgentLifecycleService, IAgentProfileService, @@ -80,7 +80,7 @@ class FakeAgentHandle { private readonly services = new Map(); constructor(readonly id: string) { this.services.set(IEventBus, this.bus); - this.services.set(IAgentConversationReconciliationRegistry, { + this.services.set(IAgentConversationUndoReconciliationRegistry, { register: () => ({ dispose: () => {} }), }); this.accessor = { diff --git a/packages/kap-server/test/sessions.test.ts b/packages/kap-server/test/sessions.test.ts index 33165627e8..5bcc8a9318 100644 --- a/packages/kap-server/test/sessions.test.ts +++ b/packages/kap-server/test/sessions.test.ts @@ -614,8 +614,8 @@ 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 rewind service. - expect(res.body.stack).toEqual(expect.stringContaining('rewindService')); + // source — the precheck/throw now lives in the undo service. + expect(res.body.stack).toEqual(expect.stringContaining('undoService')); }); it('rejects an unsupported action suffix (40001)', async () => { From 87d08d586dc92019befb1cf787eff2dc12348550 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 21:27:46 +0800 Subject: [PATCH 7/7] fix(agent-core-v2): stabilize undo restoration --- .../agent/contextMemory/contextTranscript.ts | 1 + .../agent/fullCompaction/fullCompaction.ts | 2 - .../fullCompaction/fullCompactionService.ts | 11 +--- .../agent-core-v2/src/agent/prompt/prompt.ts | 2 - .../src/agent/prompt/promptService.ts | 15 +---- .../src/agent/undo/undoService.ts | 4 -- .../contextMemory/contextTranscript.test.ts | 25 ++++++++ .../test/agent/undo/undo.test.ts | 57 ----------------- .../test/app/gateway/gateway.test.ts | 1 - .../test/services/transcript.test.ts | 62 +++++++++++++++++++ 10 files changed, 90 insertions(+), 90 deletions(-) diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index acd7997516..e908d527a3 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -266,6 +266,7 @@ export function createContextTranscriptReducer(): ContextTranscriptReducer { (!cutEntry.opensTurn || turn.turnId !== cutEntry.turnId), ); } + currentTurnId = turns.at(-1)?.turnId; resetOpenState(); }; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts index ec2693e7c3..e8d5a35e25 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompaction.ts @@ -4,7 +4,6 @@ import type { } from './types'; import { createDecorator } from "#/_base/di/instantiation"; import type { Event } from '#/_base/event'; -import type { IDisposable } from '#/_base/di/lifecycle'; import type { Hooks } from '#/hooks'; export interface FullCompactionInput { @@ -26,7 +25,6 @@ export interface IAgentFullCompactionService { readonly compacting: FullCompactionTask | null; begin(input: FullCompactionInput): boolean; cancel(): Promise; - pauseLaunching(): IDisposable; readonly hooks: Hooks<{ onWillCompact: FullCompactionTask; diff --git a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts index e546cc1fd4..d5ce1d1f72 100644 --- a/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts +++ b/packages/agent-core-v2/src/agent/fullCompaction/fullCompactionService.ts @@ -1,4 +1,4 @@ -import { Disposable, toDisposable, type IDisposable } from "#/_base/di/lifecycle"; +import { Disposable } from "#/_base/di/lifecycle"; import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; @@ -114,7 +114,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull private consecutiveOverflowCompactions = 0; private activeTurnId: number | undefined; private contextInjectorService: IAgentContextInjectorService | undefined; - private launchPauseDepth = 0; constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @@ -248,7 +247,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } begin(input: FullCompactionInput): boolean { - if (this.launchPauseDepth > 0) return false; if (this._compacting) return false; const data: CompactionBeginData = { source: input.source, instruction: input.instruction }; if (!this.reserveCompactionSlot(data.source)) return false; @@ -283,13 +281,6 @@ export class AgentFullCompactionService extends Disposable implements IAgentFull } catch {} } - pauseLaunching(): IDisposable { - this.launchPauseDepth += 1; - return toDisposable(() => { - this.launchPauseDepth = Math.max(0, this.launchPauseDepth - 1); - }); - } - private reserveCompactionSlot(source: CompactionBeginData['source']): boolean { if (source === 'manual') { this.compactionCountInTurn = 0; diff --git a/packages/agent-core-v2/src/agent/prompt/prompt.ts b/packages/agent-core-v2/src/agent/prompt/prompt.ts index 528f2d8ae7..d5045dd025 100644 --- a/packages/agent-core-v2/src/agent/prompt/prompt.ts +++ b/packages/agent-core-v2/src/agent/prompt/prompt.ts @@ -1,5 +1,4 @@ import { createDecorator } from '#/_base/di/instantiation'; -import type { IDisposable } from '#/_base/di/lifecycle'; import type { ContextMessage } from '#/agent/contextMemory/types'; import type { Turn, TurnResult } from '#/agent/loop/loop'; import type { Hooks } from '#/hooks'; @@ -57,7 +56,6 @@ export interface IAgentPromptService { inject(message: ContextMessage): Promise; retry(): Promise; clear(): void; - pauseLaunching(): IDisposable; readonly hooks: Hooks<{ onBeforeSubmitPrompt: PromptSubmitContext }>; } diff --git a/packages/agent-core-v2/src/agent/prompt/promptService.ts b/packages/agent-core-v2/src/agent/prompt/promptService.ts index f224bcee08..a6af045452 100644 --- a/packages/agent-core-v2/src/agent/prompt/promptService.ts +++ b/packages/agent-core-v2/src/agent/prompt/promptService.ts @@ -9,7 +9,6 @@ import { InstantiationType } from '#/_base/di/extensions'; import { IInstantiationService } from '#/_base/di/instantiation'; -import { type IDisposable, toDisposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { extractImageCompressionCaptions } from '#/agent/media/image-compress'; import { userCancellationReason } from '#/_base/utils/abort'; @@ -63,7 +62,6 @@ export class AgentPromptService implements IAgentPromptService { private readonly pending: Record[] = []; private readonly steered = new Map(); private launching = false; - private pauseCount = 0; private fullCompactionService: IAgentFullCompactionService | undefined; readonly hooks = { onBeforeSubmitPrompt: new OrderedHookSlot() }; @@ -164,19 +162,8 @@ export class AgentPromptService implements IAgentPromptService { this.context.clear(); } - pauseLaunching(): IDisposable { - this.pauseCount++; - let released = false; - return toDisposable(() => { - if (released) return; - released = true; - this.pauseCount--; - if (this.pauseCount === 0) void this.startNext(); - }); - } - private async startNext(): Promise { - if (this.active !== undefined || this.launching || this.pauseCount > 0) return; + if (this.active !== undefined || this.launching) return; const item = this.pending.shift(); if (item === undefined) return; this.launching = true; try { diff --git a/packages/agent-core-v2/src/agent/undo/undoService.ts b/packages/agent-core-v2/src/agent/undo/undoService.ts index 1ce4923f22..47a12af8bb 100644 --- a/packages/agent-core-v2/src/agent/undo/undoService.ts +++ b/packages/agent-core-v2/src/agent/undo/undoService.ts @@ -102,8 +102,6 @@ export class AgentConversationUndoService } private async undoNow(turns: number): Promise { - const pause = this.prompt.pauseLaunching(); - const compactionPause = this.fullCompaction.pauseLaunching(); let quiescence: IDisposable | undefined; try { quiescence = this.loop.tryAcquireQuiescence(); @@ -125,9 +123,7 @@ export class AgentConversationUndoService this.eventBus.publish({ type: 'context.undone', turns }); return turns; } finally { - compactionPause.dispose(); quiescence?.dispose(); - pause.dispose(); } } 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 0af1a089a3..dd1f50e24e 100644 --- a/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts +++ b/packages/agent-core-v2/test/agent/contextMemory/contextTranscript.test.ts @@ -402,6 +402,31 @@ describe('reduceContextTranscript', () => { expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); }); + it('keeps restored task notifications off removed turn ids after undo', () => { + const result = reduceContextTranscript([ + promptTurn('u0', { kind: 'user' }), + appendMessage(userMessage('u0', { kind: 'user' })), + ...assistantStep('s0', 'a0'), + promptTurn('u1', { kind: 'user' }), + appendMessage(userMessage('u1', { kind: 'user' })), + ...assistantStep('s1', 'a1'), + undo(1), + appendMessage( + userMessage('task done', { + kind: 'task', + taskId: 'bash-001', + status: 'completed', + notificationId: 'task:bash-001:completed', + }), + ), + ]); + + expect(texts(result)).toEqual(['u0', 'a0', 'task done']); + expect(result.turnIds).toEqual([0, 0, 0]); + expect(result.turns.map((turn) => turn.turnId)).toEqual([0]); + expect(result.stableTurnIds).toBe(true); + }); + it('uses an active cancel turn id to resolve an idle steer before its first loop event', () => { const result = reduceContextTranscript([ promptTurn('u0', { kind: 'user' }), diff --git a/packages/agent-core-v2/test/agent/undo/undo.test.ts b/packages/agent-core-v2/test/agent/undo/undo.test.ts index 50f86adc30..97b5135719 100644 --- a/packages/agent-core-v2/test/agent/undo/undo.test.ts +++ b/packages/agent-core-v2/test/agent/undo/undo.test.ts @@ -356,63 +356,6 @@ describe('AgentConversationUndoService', () => { }, ); - it('prevents compaction from starting until undo reconciliation finishes', async () => { - setup(); - const compaction = ctx.get(IAgentFullCompactionService); - let beginAccepted: boolean | undefined; - ctx.get(IAgentConversationUndoReconciliationRegistry).register({ - id: 'test.compaction-admission', - reconcileAfterUndo: async () => { - beginAccepted = compaction.begin({ source: 'manual' }); - }, - }); - ctx.appendTurnExchange('u1', 'a1'); - - await ctx.get(IAgentConversationUndoService).undo(1); - - expect(beginAccepted).toBe(false); - expect(() => compaction.begin({ source: 'manual' })).toThrowError( - expect.objectContaining({ code: ErrorCodes.COMPACTION_UNABLE }), - ); - }); - - it('re-enables compaction before releasing held loop work', async () => { - setup(); - const order: string[] = []; - const compaction = ctx.get(IAgentFullCompactionService); - const originalPauseLaunching = compaction.pauseLaunching.bind(compaction); - const pauseLaunching = vi.spyOn(compaction, 'pauseLaunching').mockImplementation(() => { - const lease = originalPauseLaunching(); - return { - dispose: () => { - order.push('compaction'); - lease.dispose(); - }, - }; - }); - const loop = ctx.get(IAgentLoopService); - const originalTryAcquireQuiescence = loop.tryAcquireQuiescence.bind(loop); - const tryAcquireQuiescence = vi.spyOn(loop, 'tryAcquireQuiescence').mockImplementation(() => { - const lease = originalTryAcquireQuiescence(); - if (lease === undefined) return undefined; - return { - dispose: () => { - order.push('loop'); - lease.dispose(); - }, - }; - }); - ctx.appendTurnExchange('u1', 'a1'); - - try { - await ctx.get(IAgentConversationUndoService).undo(1); - expect(order).toEqual(['compaction', 'loop']); - } finally { - tryAcquireQuiescence.mockRestore(); - pauseLaunching.mockRestore(); - } - }); - it('serializes concurrent undos through projection reconciliation', async () => { setup(); ctx.appendTurnExchange('u1', 'a1'); 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 5a711cb608..e2194cd5ed 100644 --- a/packages/agent-core-v2/test/app/gateway/gateway.test.ts +++ b/packages/agent-core-v2/test/app/gateway/gateway.test.ts @@ -57,7 +57,6 @@ describe('RestGateway', () => { inject: () => Promise.resolve(undefined), retry: () => Promise.resolve(undefined), clear: () => {}, - pauseLaunching: () => ({ dispose: () => {} }), hooks: createHooks(['onBeforeSubmitPrompt']) as IAgentPromptService['hooks'], }; diff --git a/packages/kap-server/test/services/transcript.test.ts b/packages/kap-server/test/services/transcript.test.ts index 341bcf74a3..072bb58c28 100644 --- a/packages/kap-server/test/services/transcript.test.ts +++ b/packages/kap-server/test/services/transcript.test.ts @@ -22,10 +22,12 @@ import { ISessionMetadata, ISessionTodoService, SessionInteractionService, + reduceContextTranscript, type DomainEvent, type ContextMessage, type ISessionScopeHandle, type Scope, + type WireRecord, } from '@moonshot-ai/agent-core-v2'; import { AgentTranscript, @@ -1602,6 +1604,66 @@ describe('bindSessionTranscript', () => { binding.dispose(); }); + it('omits a phantom turn when undo is followed by a restored task notification', () => { + const records: WireRecord[] = [ + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'kept prompt' }], + origin: { kind: 'user' }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'kept prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'turn.prompt', + input: [{ type: 'text', text: 'undone prompt' }], + origin: { kind: 'user' }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'undone prompt' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { type: 'context.undo', count: 1 }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'background task completed' }], + toolCalls: [], + origin: { + kind: 'task', + taskId: 'bash-001', + status: 'completed', + notificationId: 'task:bash-001:completed', + }, + }, + }, + ]; + const transcript = reduceContextTranscript(records); + + const snapshot = groupMessagesIntoSnapshot(transcript.entries, { + turnIds: transcript.turnIds, + turns: transcript.turns, + }); + + expect( + snapshot.items + .filter((item): item is TranscriptTurn => item.kind === 'turn') + .map((turn) => ({ turnId: turn.turnId, prompt: turn.prompt })), + ).toEqual([{ turnId: 't0', prompt: 'kept prompt' }]); + }); + it('projects session Todo changes into the main live transcript', () => { const todos = new FakeTodos(); todos.setTodos([{ title: 'pre-bind', status: 'pending' }]);