diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 51b5c94d6..0f7236e7a 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -154,9 +154,8 @@ Entity-service conclusion for `agent`: | Concern | Owner | Notes | |---|---|---| | Active `Turn` handle | `turn` | `id`, `abortController`, `ready`, `result` | -| Turn id allocation | `turn` | Restored from `turn.launch` records | +| Turn id allocation | `turn` | Restored from `turn.prompt` records and `context.append_loop_event` turn ids | | Turn lifecycle hooks | `turn` | `onLaunched`, `onEnded`, `beforeStep`, `afterStep` | -| `turn.launch` record type | `turn` | Durable fact carried by `wireRecord` | | `turn.started` / `turn.ended` live events | `turn` | Live event stream | `turn` must not own these: diff --git a/packages/agent-core-v2/docs/rw-model-design.md b/packages/agent-core-v2/docs/rw-model-design.md index e05d361f0..ccb2a71ea 100644 --- a/packages/agent-core-v2/docs/rw-model-design.md +++ b/packages/agent-core-v2/docs/rw-model-design.md @@ -7,6 +7,12 @@ > > 阅读顺序:§1 问题 → §2 概念模型(核心) → §3–§6 各原语规范 → §7 订阅协议 → > §8 回环控制 → §9 迁移路径。附录 A 是"现有机制 → 新模型"的逐条映射。 +> +> **更新注**:本文档撰写时,`todo.set` / `turn.launch` / `context.splice` 仍是 +> agent-core-v2 的 wire record 类型。后续的重构(v1 vocabulary 对齐)已删除这三个 +> replay-only / pre-alignment 类型,统一改用 v1 的 `tools.update_store` +> (`key: 'todo'`)、`turn.prompt`、`context.append_message` 等。本文档中涉及这些 +> 类型的示例与映射,按上述替换理解。 --- diff --git a/packages/agent-core-v2/docs/service-design.md b/packages/agent-core-v2/docs/service-design.md index f55f215c9..9cf5fae4d 100644 --- a/packages/agent-core-v2/docs/service-design.md +++ b/packages/agent-core-v2/docs/service-design.md @@ -190,10 +190,12 @@ an **event**, or a **hook**. From first principles, they answer three different projected onto the wire, not handled by a direct call alone. `permission.set_mode`, `goal.create/update/clear`, and `plan_mode.enter/exit` are all in this category. Note that the wire is the *durable record*, not the live notification channel: a live - `spliceHistory(...)` call appends a `context.splice` record to the wire *and* applies it, - and `contextMemory` then fires `hooks.onSpliced`, which `contextSize` / `loop` / - `background` / `dynamicInjector` actually subscribe to. Those listeners - react to the **hook**, not the wire — the wire is what makes the splice replayable. + context mutation appends v1 wire records (`context.append_message` / + `context.append_loop_event` / `context.undo` / `context.clear` / + `context.apply_compaction`) *and* applies them, and `contextMemory` then fires a + `context.spliced` event, which `contextSize` / `loop` / `background` / `dynamicInjector` + actually subscribe to. Those listeners react to the **event**, not the wire — the wire is + what makes the mutation replayable. ### One-sentence rule diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts index c4d80e1a4..44a044f9d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextMemoryService.ts @@ -4,8 +4,7 @@ * 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`); `context.splice` stays registered replay-only for - * protocol 1.5 sessions and is no longer dispatched live. + * `applyCompaction`). * 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 @@ -51,17 +50,6 @@ import { import type { LoopRecordedEvent } from './loopEventFold'; import type { ContextMessage } from './types'; -declare module '#/agent/wireRecord/wireRecord' { - interface WireRecordMap { - 'context.splice': { - start: number; - deleteCount: number; - messages: readonly ContextMessage[]; - tokens?: number; - }; - } -} - declare module '#/app/event/eventBus' { interface DomainEventMap { 'context.spliced': { diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts index de72d1846..1bc279a7d 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextOps.ts @@ -3,8 +3,7 @@ * 1.4 Ops `context.append_message` (`contextAppendMessage`) / `context.clear` * (`contextClear`) / `context.apply_compaction` (`contextApplyCompaction`) / * `context.undo` (`contextUndo`) / `context.append_loop_event` - * (`contextAppendLoopEvent`) for the per-agent conversation history, plus the - * legacy `context.splice` (`contextSplice`) Op. + * (`contextAppendLoopEvent`) for the per-agent conversation history. * * Declares the history as `ContextMessage[]` (initial `[]`); every Op's `apply` * is a pure array transform that returns a NEW reference on change and the SAME @@ -18,18 +17,16 @@ * same on-disk shape the v1 loop writes — and `contextAppendLoopEvent` folds * them into assistant / tool messages (see `loopEventFold.ts`) both at live * dispatch time and on replay, so v1- and v2-written sessions reduce - * identically. `context.splice` (the pre-1.4 primitive) stays registered - * replay-only so sessions written at wire protocol 1.5 still replay - * (newer-version passthrough, no migration); the live path no longer - * dispatches it. The swarm-mode exit reminder removal is a cross-model fold: + * identically. The swarm-mode exit reminder removal is a cross-model fold: * `ContextModel` registers a reducer on `swarm_mode.exit` (see * `popSwarmModeReminder`) so the pop replays from the `swarm_mode.exit` record * itself, exactly like v1's restore-time `popMatchedMessage`. * * Blob handling is declared as a `ModelBlobCodec` on `ContextModel.blobs`: * - `dehydrate(record, transform)`: at dispatch time, traverses message content - * in `context.splice` and `context.append_message` records, passing each - * `ContentPart[]` through `transform` to offload oversized data URIs. + * in `context.append_message` and `context.append_loop_event` records, + * passing each `ContentPart[]` through `transform` to offload oversized data + * URIs. * - `rehydrate(state, transform)`: after replay, traverses the surviving final * state and loads `blobref:` URLs back to inline data — skipping I/O for * data that was compacted away during the session. @@ -75,12 +72,6 @@ async function dehydrateRecord( record: PersistedRecord, transform: PartsTransformer, ): Promise { - if (record.type === 'context.splice') { - const messages = record['messages']; - if (!Array.isArray(messages)) return record; - const { changed, result } = await dehydrateMessages(messages as ContextMessage[], transform); - return changed ? { ...record, messages: result } : record; - } if (record.type === 'context.append_message') { const message = record['message'] as ContextMessage | undefined; if (message === undefined) return record; @@ -117,10 +108,6 @@ export const ContextModel = defineModel('contextMemory', () => }, }, reducers: { - // v1 parity: replaying (or dispatching) `swarm_mode.exit` pops the - // swarm-mode enter reminder when it is the last message — the removal is - // derived from the exit record itself instead of persisting a splice, - // mirroring v1's `popMatchedMessage` during `swarm_mode.exit` restore. 'swarm_mode.exit': popSwarmModeReminder, }, }); @@ -133,23 +120,6 @@ function popSwarmModeReminder(state: ContextMessage[], _payload: unknown): Conte return resetFold(state.slice(0, -1)) as ContextMessage[]; } -export interface ContextSplicePayload { - readonly start: number; - readonly deleteCount: number; - readonly messages: readonly ContextMessage[]; - readonly tokens?: number; -} - -/** @deprecated Legacy 1.5 record type; registered replay-only — the live path never dispatches it. */ -export const contextSplice = defineOp(ContextModel, 'context.splice', { - apply: (state, p: ContextSplicePayload): ContextMessage[] => { - if (p.deleteCount === 0 && p.messages.length === 0) return state; - const next = state.slice(); - next.splice(p.start, p.deleteCount, ...p.messages); - return resetFold(next) as ContextMessage[]; - }, -}); - export interface ContextMessagePayload { readonly message: ContextMessage; } @@ -163,14 +133,6 @@ export interface ContextLoopEventPayload { readonly event: LoopRecordedEvent; } -/** - * Folds a `context.append_loop_event` record into the history (see - * `loopEventFold.ts`). Since the v1.4 wire-parity alignment the v2 live loop - * dispatches (and persists) these records itself — one per streamed step - * fragment, byte-compatible with the v1 loop — so the same fold runs both on - * live dispatch and when `WireService.replay` reduces v1- or v2-written - * sessions. - */ export const contextAppendLoopEvent = defineOp(ContextModel, 'context.append_loop_event', { apply: (state, p: ContextLoopEventPayload): ContextMessage[] => foldLoopEvent(state, p.event) as ContextMessage[], diff --git a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts index 8f111dd1c..bd69707f9 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/contextTranscript.ts @@ -26,7 +26,6 @@ * at compaction summaries / clear floor) * - `context.clear` → keep prior transcript entries but reset the * folded view - * - `context.splice` → array splice (legacy 1.5 / internal) */ import { type ContentPart, type ToolCall } from '#/app/llmProtocol/message'; @@ -201,23 +200,6 @@ export function reduceContextTranscript(records: Iterable): Con case 'context.append_loop_event': applyLoopEvent(record['event'] as LoopRecordedEvent); break; - case 'context.splice': { - const start = record['start'] as number; - const deleteCount = record['deleteCount'] as number; - const messages = record['messages'] as readonly ContextMessage[]; - if (deleteCount === 0 && messages.length === 0) break; - // The transcript is append-only (matches the TUI / replay-builder - // transcript): a splice's inserted messages land at the tail and the - // deleted messages stay for display, so legacy compaction-via-splice - // keeps the pre-compaction prefix. `foldedLength` tracks the literal - // live-context length after the splice. - for (const message of messages) transcript.push(toMutableEntry(message)); - const clampedStart = Math.min(Math.max(start, 0), foldedLength); - const removed = Math.min(deleteCount, foldedLength - clampedStart); - foldedLength = foldedLength - removed + messages.length; - resetOpenState(); - break; - } case 'context.apply_compaction': { // The live context folds into `[...keptUserMessages, summary]`; the // transcript keeps the full history and appends the summary marker. diff --git a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts index 809f091ab..f9df583c0 100644 --- a/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts +++ b/packages/agent-core-v2/src/agent/contextMemory/loopEventFold.ts @@ -186,8 +186,8 @@ export function foldLoopEvent( /** * Clear fold bookkeeping after an op that invalidates any open exchange - * (`context.undo` / `context.clear` / `context.apply_compaction` / - * `context.splice`). Returns the same state reference with a fresh fold ctx. + * (`context.undo` / `context.clear` / `context.apply_compaction`). Returns + * the same state reference with a fresh fold ctx. */ export function resetFold(state: readonly ContextMessage[]): readonly ContextMessage[] { foldCtxMap.set(state, { openStepUuid: undefined, pending: new Set(), deferred: [] }); diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index e0559c03a..0a5cb7b16 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -1158,14 +1158,10 @@ function taskOriginsFromRecord(record: WireRecord): readonly TaskNotificationOri const raw = record as { readonly type: string; readonly message?: unknown; - readonly messages?: unknown; }; if (raw.type === 'context.append_message') { return taskOriginFromMessage(raw.message); } - if (raw.type === 'context.splice' && Array.isArray(raw.messages)) { - return raw.messages.flatMap(taskOriginFromMessage); - } return []; } diff --git a/packages/agent-core-v2/src/agent/turn/turnOps.ts b/packages/agent-core-v2/src/agent/turn/turnOps.ts index 1a419bef7..d5958dea2 100644 --- a/packages/agent-core-v2/src/agent/turn/turnOps.ts +++ b/packages/agent-core-v2/src/agent/turn/turnOps.ts @@ -14,11 +14,6 @@ * observed in a replayed loop event — the v1 `observeRestoredTurnId` semantics. * The `turn.started` / `turn.ended` / `error` signals are not part of this Op * and remain on their existing path. Consumed by the Agent-scope `turnService`. - * - * `turn.launch` (`launchTurn`) is the pre-1.4 record type: it stays registered so - * sessions written at wire protocol 1.5 still replay (restored via the - * newer-version passthrough, no migration), but the live write path no longer - * emits it. */ import { defineModel } from '#/wire/model'; @@ -44,13 +39,6 @@ export const TurnModel = defineModel('turn', () => ({ nextTurnId }, }); -function advanceTurnId(s: TurnModelState, turnId: number): TurnModelState { - if (Number.isInteger(turnId) && turnId >= s.nextTurnId) { - return { nextTurnId: turnId + 1 }; - } - return s; -} - export interface PromptTurnPayload { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; @@ -60,11 +48,6 @@ export const promptTurn = defineOp(TurnModel, 'turn.prompt', { apply: (s, _p: PromptTurnPayload): TurnModelState => ({ nextTurnId: s.nextTurnId + 1 }), }); -/** @deprecated Legacy 1.5 record type; kept registered for replay of old sessions. */ -export const launchTurn = defineOp(TurnModel, 'turn.launch', { - apply: (s, p: { turnId: number }): TurnModelState => advanceTurnId(s, p.turnId), -}); - export interface SteerTurnPayload { readonly input: readonly ContentPart[]; readonly origin: PromptOrigin; @@ -85,7 +68,6 @@ export const cancelTurn = defineOp(TurnModel, 'turn.cancel', { declare module '#/agent/wireRecord/wireRecord' { interface WireRecordMap { 'turn.prompt': PromptTurnPayload; - 'turn.launch': { readonly turnId: number }; 'turn.steer': SteerTurnPayload; 'turn.cancel': CancelTurnPayload; } diff --git a/packages/agent-core-v2/src/agent/turn/turnService.ts b/packages/agent-core-v2/src/agent/turn/turnService.ts index cb07ee634..48d125013 100644 --- a/packages/agent-core-v2/src/agent/turn/turnService.ts +++ b/packages/agent-core-v2/src/agent/turn/turnService.ts @@ -9,8 +9,7 @@ * through `wire.signal` (legacy channel); `turn.ended` / `error` publish to * `IEventBus` and are also emitted through `wire.signal`. `wire.replay` rebuilds * the counter silently so resumed sessions keep allocating fresh ids without - * re-firing anything. `turn.launch` (`launchTurn`) stays registered only to - * replay sessions written at wire protocol 1.5. Bound at Agent scope. + * re-firing anything. Bound at Agent scope. */ import { createControlledPromise } from '@antfu/utils'; diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 9ee2c9922..fbc1deb81 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -208,9 +208,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const agents = handle.accessor.get(IAgentLifecycleService); if (agents.getHandle(MAIN_AGENT_ID) === undefined) { const main = await ensureMainAgent(handle); - // Resolve context memory BEFORE restoring so its `context.splice` resumer - // is registered; otherwise the wire replay applies splices into a void and - // the restored transcript never lands in context memory. + // Resolve context memory BEFORE restoring so its reducers are registered; + // otherwise the wire replay applies context records into a void and the + // restored transcript never lands in context memory. main.accessor.get(IAgentContextMemoryService); const mainWireRecord = main.accessor.get(IAgentWireRecordService); await mainWireRecord.restore(); diff --git a/packages/agent-core-v2/src/session/todo/sessionTodo.ts b/packages/agent-core-v2/src/session/todo/sessionTodo.ts index 96320e0b5..f159d449a 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodo.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodo.ts @@ -2,9 +2,9 @@ * `todo` domain (L4) — `ISessionTodoService` contract. * * The session-shared todo list: an in-memory list materialized from the main - * agent's `todo.set` wire records, mutated through `setTodos` (which appends a - * fresh `todo.set` to the main agent's wire), and readable by every agent in - * the session. Bound at Session scope. + * agent's `tools.update_store` (`key: 'todo'`) wire records, mutated through + * `setTodos` (which appends a fresh `tools.update_store` to the main agent's + * wire), and readable by every agent in the session. Bound at Session scope. */ import { createDecorator } from '#/_base/di/instantiation'; @@ -17,11 +17,11 @@ export interface ISessionTodoService { /** Current in-memory todo list (the materialized main-agent wire state). */ getTodos(): readonly TodoItem[]; - /** Replace the whole list: appends a `todo.set` to the main agent's wire. */ + /** Replace the whole list: appends a `tools.update_store` (`key: 'todo'`) to the main agent's wire. */ setTodos(todos: readonly TodoItem[]): void; /** Clear the list (equivalent to `setTodos([])`). */ clear(): void; - /** Fires when the materialized list changes (after a `todo.set` is applied); carries the sanitized list. */ + /** Fires when the materialized list changes (after a `tools.update_store` is applied); carries the sanitized list. */ readonly onDidChange: Event; } diff --git a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts index 07a074889..d64f43223 100644 --- a/packages/agent-core-v2/src/session/todo/sessionTodoService.ts +++ b/packages/agent-core-v2/src/session/todo/sessionTodoService.ts @@ -115,7 +115,7 @@ export class SessionTodoService extends Disposable implements ISessionTodoServic // Registered on the main agent's wire by `onDidCreateMain`, which fires in // `ensureMainAgent` strictly before that wire's `replay`. Bridge model // changes to `onDidChange`: replay applies silently (no notification), so - // this fires only for live `todo.set` writes, carrying the sanitized model. + // this fires only for live `tools.update_store` (`key: 'todo'`) writes, carrying the sanitized model. const disposable = wire.subscribe(TodoModel, (state) => { this.onDidChangeEmitter.fire(state); }); diff --git a/packages/agent-core-v2/src/session/todo/todoItem.ts b/packages/agent-core-v2/src/session/todo/todoItem.ts index 3d18d29e6..f30290207 100644 --- a/packages/agent-core-v2/src/session/todo/todoItem.ts +++ b/packages/agent-core-v2/src/session/todo/todoItem.ts @@ -1,8 +1,9 @@ /** * `todo` domain (L4) — todo item data shape and pure render helpers. * - * `TodoItem` / `TodoStatus` are the persistent shape carried by the `todo.set` - * wire record and rendered by the `TodoListTool` and the stale reminder. Pure + * `TodoItem` / `TodoStatus` are the persistent shape carried by the + * `tools.update_store` (`key: 'todo'`) wire record and rendered by the + * `TodoListTool` and the stale reminder. Pure * and scope-less — no scoped state lives here. The session todo list itself is * owned by `ISessionTodoService`. */ diff --git a/packages/agent-core-v2/src/session/todo/todoOps.ts b/packages/agent-core-v2/src/session/todo/todoOps.ts index de391f195..12fb78e66 100644 --- a/packages/agent-core-v2/src/session/todo/todoOps.ts +++ b/packages/agent-core-v2/src/session/todo/todoOps.ts @@ -9,13 +9,12 @@ * 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. A replay-only `todo.set` Op is kept for early v2 - * development logs that persisted the pre-alignment record type. 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, - * on `wire.onRestored`, reads the rebuilt Model back from that same wire. The - * Ops register into the global `OP_REGISTRY` at import time, so they are in - * place before the main agent replays. + * 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, on `wire.onRestored`, reads the + * rebuilt Model back from that same wire. The Ops register into the global + * `OP_REGISTRY` at import time, so they are in place before the main agent + * replays. */ import { defineModel } from '#/wire/model'; @@ -36,9 +35,3 @@ export const todoSet = defineOp(TodoModel, 'tools.update_store', { apply: (s, p: ToolStoreUpdatePayload): TodoModelState => p.key === 'todo' ? readTodoItems(p.value) : s, }); - -/** @deprecated Replay-only: early v2 dev logs persisted `todo.set` before the v1 alignment. */ -export const legacyTodoSet = defineOp(TodoModel, 'todo.set', { - apply: (_s, p: { readonly todos: readonly TodoItem[] }): TodoModelState => - readTodoItems(p.todos), -}); diff --git a/packages/agent-core-v2/src/session/todo/tools/todo-list.ts b/packages/agent-core-v2/src/session/todo/tools/todo-list.ts index 02a392ab6..e271379d0 100644 --- a/packages/agent-core-v2/src/session/todo/tools/todo-list.ts +++ b/packages/agent-core-v2/src/session/todo/tools/todo-list.ts @@ -8,7 +8,7 @@ * - `resolveExecution({})` — query the current list * * The list is session-shared: the tool reads/writes `ISessionTodoService`, - * which persists every change as a `todo.set` wire record on the main agent. + * which persists every change as a `tools.update_store` (`key: 'todo'`) wire record on the main agent. * Self-registers via `registerTool(TodoListTool)` at module load; the Eager * `AgentBuiltinToolsRegistrar` instantiates one per agent (resolving the * Session-scope `ISessionTodoService` from the parent scope) and registers it diff --git a/packages/agent-core-v2/test/contextMemory/context.test.ts b/packages/agent-core-v2/test/contextMemory/context.test.ts index 05a952a83..8353eb164 100644 --- a/packages/agent-core-v2/test/contextMemory/context.test.ts +++ b/packages/agent-core-v2/test/contextMemory/context.test.ts @@ -2,7 +2,6 @@ import type { Message } from '#/app/llmProtocol/message'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { estimateTokensForMessages } from '#/_base/utils/tokens'; -import { contextSplice } from '#/agent/contextMemory/contextOps'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IAgentWireService } from '#/wire/tokens'; import type { IWireService } from '#/wire/wireService'; @@ -439,69 +438,6 @@ describe('Agent context', () => { ]); }); - it('preserves deferred reminders when compaction keeps a pending tool exchange', async () => { - ctx.appendUserMessage([{ type: 'text', text: 'old prompt' }]); - ctx.appendContextPartiallyResolvedParallelToolExchange(); - - ctx.appendSystemReminder('first reminder', { - kind: 'injection', - variant: 'host', - }); - wire.dispatch(contextSplice({ - start: 0, - deleteCount: 1, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'summary of old prompt' }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - }, - ], - })); - ctx.appendSystemReminder('second reminder', { - kind: 'injection', - variant: 'host', - }); - - // The open second call is synthesized; both reminders stay deferred behind - // the closed exchange. - expect(ctx.project().map((message) => message.role)).toEqual([ - 'assistant', - 'user', - 'assistant', - 'tool', - 'tool', - 'user', - 'user', - ]); - - context.append( - { - role: 'tool', - content: [{ type: 'text', text: 'two result' }], - toolCalls: [], - toolCallId: 'call_open_two', - }, - ); - - expect(ctx.project().map((message) => message.role)).toEqual([ - 'assistant', - 'user', - 'assistant', - 'tool', - 'tool', - 'user', - 'user', - ]); - expect(ctx.project()[5]?.content).toEqual([ - { type: 'text', text: '\nfirst reminder\n' }, - ]); - expect(ctx.project()[6]?.content).toEqual([ - { type: 'text', text: '\nsecond reminder\n' }, - ]); - }); - it('clears context before the next LLM request', async () => { profile.update({ activeToolNames: [] }); ctx.appendUserMessage([{ type: 'text', text: 'stale user message' }]); @@ -519,38 +455,6 @@ describe('Agent context', () => { `); }); - it('uses compacted summary plus recent messages', async () => { - profile.update({ activeToolNames: [] }); - ctx.appendUserMessage([{ type: 'text', text: 'old user message' }]); - ctx.appendUserMessage([{ type: 'text', text: 'recent user message' }]); - wire.dispatch(contextSplice({ - start: 0, - deleteCount: 1, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'summary of old context' }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - }, - ], - tokens: 20, - })); - expect(context.get()[0]?.origin).toEqual({ kind: 'compaction_summary' }); - - ctx.mockNextResponse({ type: 'text', text: 'after compaction' }); - await ctx.rpc.prompt({ input: [{ type: 'text', text: 'new prompt' }] }); - - await ctx.untilTurnEnd(); - expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` - system: - tools: [] - messages: - assistant: text "summary of old context" - user: text "recent user message\\n\\nnew prompt" - `); - }); - it('includes new user messages as pending until the next usage update', () => { ctx.appendAssistantTextWithUsage(1, 'previous answer', 1_000); expect(contextSize.get().measured).toBe(1_000); @@ -738,131 +642,6 @@ describe('Agent context', () => { expect(context.get().map((m) => m.role)).toEqual(['user', 'assistant']); }); - it('stops at compaction summary and records the requested undo count', () => { - ctx.appendUserMessage([{ type: 'text', text: 'old user message' }]); - wire.dispatch(contextSplice({ - start: 0, - deleteCount: 1, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'summary of compacted context' }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - }, - ], - tokens: 20, - })); - ctx.appendUserMessage([{ type: 'text', text: 'recent user message' }]); - context.append( - { - role: 'assistant', - content: [{ type: 'text', text: 'recent answer' }], - toolCalls: [], - origin: undefined, - }, - ); - ctx.newEvents(); - - expect(() => { - ctx.undoHistory(2); - }).toThrow( - 'Cannot undo 2 prompts; only 1 prompt can be undone in the active context after the last compaction.', - ); - - expect(context.get()).toEqual([ - expect.objectContaining({ - role: 'assistant', - origin: { kind: 'compaction_summary' }, - content: [{ type: 'text', text: 'summary of compacted context' }], - }), - ]); - expect(ctx.newEvents()).toContainEqual( - expect.objectContaining({ - type: '[wire]', - event: 'context.splice', - args: expect.objectContaining({ deleteCount: 1, messages: [] }), - }), - ); - }); - - it('restores a compacted history with later messages removed', async () => { - await expect( - ctx.restore([ - { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'old user message' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], - time: 1, - }, - { - type: 'context.splice', - start: 0, - deleteCount: 1, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'summary of compacted context' }], - toolCalls: [], - origin: { kind: 'compaction_summary' }, - }, - ], - tokens: 20, - time: 2, - }, - { - type: 'context.splice', - start: 1, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'recent user message' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], - time: 3, - }, - { - type: 'context.splice', - start: 2, - deleteCount: 0, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'recent answer' }], - toolCalls: [], - }, - ], - time: 4, - }, - { - type: 'context.splice', - start: 1, - deleteCount: 2, - messages: [], - time: 5, - }, - ]), - ).resolves.not.toThrow(); - expect(context.get()).toEqual([ - expect.objectContaining({ - role: 'assistant', - origin: { kind: 'compaction_summary' }, - content: [{ type: 'text', text: 'summary of compacted context' }], - }), - ]); - }); - it('preserves injection messages when undo removes the surrounding turn', () => { context.append(userMessage('do the work', { kind: 'user' })); context.append( diff --git a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts index 8068d65e3..d28f96552 100644 --- a/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts +++ b/packages/agent-core-v2/test/contextMemory/splice-replay.test.ts @@ -2,10 +2,10 @@ * `AgentContextMemoryService` wire contract, exercised without the full agent * harness (mirror of `test/goal/goal-wire.test.ts`): a `TestInstantiationService` * + `InMemoryStorageService` + `AppendLogStore` + `WireService` + stub - * `IAgentBlobService`. Covers the splice Ops' NEW-reference + flat-record shape, - * the live-only `onSpliced` hook (silent on replay), and — load-bearing — the - * blob dehydrate-on-dispatch ↔ rehydrate-on-replay round-trip via - * `ContextModel.blobs`. + * `IAgentBlobService`. Covers the context Ops' NEW-reference + flat-record + * shape, the live-only `context.spliced` event (silent on replay), and — + * load-bearing — the blob dehydrate-on-dispatch ↔ rehydrate-on-replay + * round-trip via `ContextModel.blobs`. */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; @@ -21,7 +21,6 @@ import { contextAppendMessage, contextApplyCompaction, contextClear, - contextSplice, contextUndo, } from '#/agent/contextMemory/contextOps'; import { ContextSizeModel, contextSizeMeasured } from '#/agent/contextSize/contextSizeOps'; @@ -191,7 +190,8 @@ describe('AgentContextMemoryService (wire-backed)', () => { const model = () => host.wire.getModel(ContextModel) as readonly ContextMessage[]; host.wire.dispatch( - contextSplice({ start: 0, deleteCount: 0, messages: [userMessage('a'), userMessage('b')] }), + contextAppendMessage({ message: userMessage('a') }), + contextAppendMessage({ message: userMessage('b') }), ); expect(model()).toHaveLength(2); @@ -226,7 +226,8 @@ describe('AgentContextMemoryService (wire-backed)', () => { const records = await readRecords(host.log); expect(records.every((record) => 'payload' in record === false)).toBe(true); expect(records.map((record) => record.type)).toEqual([ - 'context.splice', + 'context.append_message', + 'context.append_message', 'context.append_message', 'context.undo', 'context.apply_compaction', @@ -403,7 +404,7 @@ describe('AgentContextMemoryService (wire-backed)', () => { const big = 'A'.repeat(200); const dataUri = `data:image/png;base64,${big}`; - host.wire.dispatch(contextSplice({ start: 0, deleteCount: 0, messages: [imageMessage(big)] })); + host.wire.dispatch(contextAppendMessage({ message: imageMessage(big) })); await host.wire.flush(); const live = host.wire.getModel(ContextModel) as readonly ContextMessage[]; @@ -412,9 +413,9 @@ describe('AgentContextMemoryService (wire-backed)', () => { const records = await readRecords(host.log); expect(blob.offloadCalls).toBeGreaterThanOrEqual(1); - const splice = records.find((record) => record.type === 'context.splice'); - expect(splice).toBeDefined(); - const persisted = (splice!['messages'] as readonly ContextMessage[])[0]!; + const appended = records.find((record) => record.type === 'context.append_message'); + expect(appended).toBeDefined(); + const persisted = appended!['message'] as ContextMessage; expect(mediaUrl(persisted).startsWith(BLOBREF)).toBe(true); expect(mediaUrl(persisted)).not.toContain(big); @@ -450,41 +451,4 @@ describe('AgentContextMemoryService (wire-backed)', () => { expect(replay.wire.getModel(ContextModel) as readonly ContextMessage[]).toHaveLength(2); }); - it('preserves the measured token count for a metadata-only splice inside the measured prefix', async () => { - const host = buildHost(KEY); - - // Seed history and mark the first two messages as measured by the LLM. - host.svc.append( - { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, - { role: 'assistant', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, - ); - host.wire.dispatch(contextSizeMeasured({ length: 2, tokens: 42 })); - await host.wire.flush(); - - const statusEvents: { contextTokens?: number }[] = []; - disposables.add(host.eventBus.subscribe('agent.status.updated', (event) => { - if ('contextTokens' in event) statusEvents.push({ contextTokens: event.contextTokens }); - })); - - // Patch providerMessageId on a message inside the measured prefix. - const history = host.svc.get(); - host.wire.dispatch(contextSplice({ - start: 1, - deleteCount: 1, - messages: [{ ...history[1]!, providerMessageId: 'mock-1' }], - })); - - // The measured aggregate must stay intact; the metadata edit does not - // change the token-countable shape of the message. - const model = host.wire.getModel(ContextSizeModel); - expect(model).toEqual({ length: 2, tokens: 42 }); - expect(statusEvents).toEqual([]); - - await host.wire.flush(); - const records = await readRecords(host.log); - // `context_size.measured` is live-only (persist: false) — nothing lands on disk. - const measuredAfterSplice = records.filter((record) => record.type === 'context_size.measured'); - expect(measuredAfterSplice).toHaveLength(0); - }); - }); diff --git a/packages/agent-core-v2/test/fullCompaction/full.test.ts b/packages/agent-core-v2/test/fullCompaction/full.test.ts index 7c45cd171..5e9895fc1 100644 --- a/packages/agent-core-v2/test/fullCompaction/full.test.ts +++ b/packages/agent-core-v2/test/fullCompaction/full.test.ts @@ -317,10 +317,8 @@ describe('FullCompaction', () => { }); ctx.appendExchange(1, 'old user one', 'old assistant one', 20); await ctx.dispatch({ - type: 'context.splice', - start: ctx.context.get().length, - deleteCount: 0, - messages: [{ role: 'assistant', content: [], toolCalls: [] }], + type: 'context.append_message', + message: { role: 'assistant', content: [], toolCalls: [] }, }); ctx.appendExchange(3, 'old user two', 'old assistant two', 40); const compacted = new Promise((resolve) => { diff --git a/packages/agent-core-v2/test/goal/injection.test.ts b/packages/agent-core-v2/test/goal/injection.test.ts index 84663fb7e..20e8f3232 100644 --- a/packages/agent-core-v2/test/goal/injection.test.ts +++ b/packages/agent-core-v2/test/goal/injection.test.ts @@ -222,13 +222,11 @@ describe('GoalInjection content', () => { }); function goalReminderRecords(persistence: InMemoryWireRecordPersistence) { - return persistence.records.filter( - (r) => - r.type === 'context.splice' && - (r as { messages?: Array<{ origin?: { kind?: string; variant?: string } }> }).messages?.some( - (m) => m.origin?.kind === 'injection' && m.origin?.variant === 'goal', - ), - ); + return persistence.records.filter((r) => { + if (r.type !== 'context.append_message') return false; + const message = (r as { message?: { origin?: { kind?: string; variant?: string } } }).message; + return message?.origin?.kind === 'injection' && message?.origin?.variant === 'goal'; + }); } async function flushedGoalReminderRecords( @@ -271,7 +269,7 @@ describe('GoalInjection integration', () => { } }); - it('main-agent dynamic injection writes a context.splice with origin.variant goal', async () => { + it('main-agent dynamic injection writes a context.append_message with origin.variant goal', async () => { await goals.createGoal({ objective: 'Ship feature X' }); await injectDynamic(injector); diff --git a/packages/agent-core-v2/test/harness/agent.ts b/packages/agent-core-v2/test/harness/agent.ts index e95be2a68..566176d97 100644 --- a/packages/agent-core-v2/test/harness/agent.ts +++ b/packages/agent-core-v2/test/harness/agent.ts @@ -2010,12 +2010,6 @@ function contextMessagesFromRecord(record: PersistedWireRecord): readonly Contex const message = record['message']; return isContextMessageLike(message) ? [message] : []; } - if (record.type === 'context.splice') { - const messages = record['messages']; - return Array.isArray(messages) - ? messages.filter(isContextMessageLike) - : []; - } return []; } diff --git a/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts b/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts index 3297424d7..f46bccc91 100644 --- a/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts +++ b/packages/agent-core-v2/test/skill/skill-tool-manager.test.ts @@ -27,29 +27,33 @@ function makeSkill(name: string, metadata: SkillDefinition['metadata'] = {}): Sk } function recordContainsSkillLoaded(record: unknown, skillName: string): boolean { - if (!isRecordWithMessages(record)) return false; - return record.messages.some((message) => { - return message.content?.some((part) => { + if (!isRecordWithMessage(record)) return false; + return ( + record.message.content?.some((part) => { return ( part.type === 'text' && typeof part.text === 'string' && part.text.includes(` { @@ -232,29 +236,27 @@ describe('ToolManager SkillTool wire behavior', () => { (record) => recordContainsSkillLoaded(record, 'review'), ); expect(skillSplice).toMatchObject({ - type: 'context.splice', - messages: [ - expect.objectContaining({ - role: 'user', - content: [ - { - type: 'text', - text: [ - 'Skill tool loaded instructions for this request. Follow them.', - '', - '', - 'body of review', - '', - ].join('\n'), - }, - ], - origin: expect.objectContaining({ - kind: 'skill_activation', - skillName: 'review', - trigger: 'model-tool', - }), + type: 'context.append_message', + message: expect.objectContaining({ + role: 'user', + content: [ + { + type: 'text', + text: [ + 'Skill tool loaded instructions for this request. Follow them.', + '', + '', + 'body of review', + '', + ].join('\n'), + }, + ], + origin: expect.objectContaining({ + kind: 'skill_activation', + skillName: 'review', + trigger: 'model-tool', }), - ], + }), }); expect(persistence.records.find((record) => record.type === 'skill.activate')).toMatchObject({ type: 'skill.activate', @@ -326,12 +328,7 @@ describe('ToolManager SkillTool restore behavior', () => { await ctx.restore([ { type: 'skill.activate', origin }, - { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [message], - }, + { type: 'context.append_message', message }, ]); expect(emit).toHaveBeenCalledWith({ diff --git a/packages/agent-core-v2/test/todo/session-todo.test.ts b/packages/agent-core-v2/test/todo/session-todo.test.ts index 65a5f7554..98a318b47 100644 --- a/packages/agent-core-v2/test/todo/session-todo.test.ts +++ b/packages/agent-core-v2/test/todo/session-todo.test.ts @@ -103,10 +103,6 @@ function makeFakeAgent(agentId: string): FakeAgent { if (record.type === 'tools.update_store' && record['key'] === 'todo') { todoState = readTodoItems(record['value']); } - // Legacy replay path: early v2 dev logs persisted `todo.set`. - if (record.type === 'todo.set') { - todoState = readTodoItems(record['todos']); - } } // Replay is silent: subscribers are NOT notified. onRestored fires after. for (const h of restoredHandlers) h(); @@ -303,12 +299,14 @@ describe('SessionTodoService', () => { expect(sub.subscribed()).toBe(0); }); - it('rebuilds the list when a todo.set record is replayed', async () => { + it('rebuilds the list when a todo tools.update_store record is replayed', async () => { const main = makeFakeAgent('main'); const lifecycle = makeLifecycleStub([main.handle]); const service = new SessionTodoService(lifecycle.service); - await main.replay([{ type: 'todo.set', todos: [{ title: 'restored', status: 'done' }] }]); + await main.replay([ + { type: 'tools.update_store', key: 'todo', value: [{ title: 'restored', status: 'done' }] }, + ]); expect(service.getTodos()).toEqual([{ title: 'restored', status: 'done' }]); }); @@ -334,15 +332,16 @@ describe('SessionTodoService', () => { expect(typeof service.onDidChange).toBe('function'); }); - it('cleans malformed items from a replayed todo.set record', async () => { + it('cleans malformed items from a replayed todo tools.update_store record', async () => { const main = makeFakeAgent('main'); const lifecycle = makeLifecycleStub([main.handle]); const service = new SessionTodoService(lifecycle.service); await main.replay([ { - type: 'todo.set', - todos: [ + type: 'tools.update_store', + key: 'todo', + value: [ { title: 'valid', status: 'done' }, { title: 'missing status' }, { title: 123, status: 'pending' }, @@ -355,13 +354,13 @@ describe('SessionTodoService', () => { expect(service.getTodos()).toEqual([{ title: 'valid', status: 'done' }]); }); - it('treats a non-array todo.set payload as an empty list on replay', async () => { + it('treats a non-array todo tools.update_store value as an empty list on replay', async () => { const main = makeFakeAgent('main'); const lifecycle = makeLifecycleStub([main.handle]); const service = new SessionTodoService(lifecycle.service); await main.replay([ - { type: 'todo.set', todos: 'not-an-array' } as unknown as PersistedRecord, + { type: 'tools.update_store', key: 'todo', value: 'not-an-array' } as unknown as PersistedRecord, ]); expect(service.getTodos()).toEqual([]); diff --git a/packages/agent-core-v2/test/turn/turn.test.ts b/packages/agent-core-v2/test/turn/turn.test.ts index 5cf953ad3..725b1624d 100644 --- a/packages/agent-core-v2/test/turn/turn.test.ts +++ b/packages/agent-core-v2/test/turn/turn.test.ts @@ -378,12 +378,7 @@ describe('Agent turn flow', () => { await ctx.restore([ { type: 'swarm_mode.enter', trigger: 'manual' }, - { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [enterReminder], - }, + { type: 'context.append_message', message: enterReminder }, { type: 'swarm_mode.exit' }, ]); @@ -392,9 +387,9 @@ describe('Agent turn flow', () => { // reminder during replay — no synthesized cleanup record is needed. expect(ctx.contextData().history).toEqual([]); expect(ctx.newEvents()).toMatchInlineSnapshot(` - [wire] swarm_mode.enter { "trigger": "manual" } - [wire] context.splice { "start": 0, "deleteCount": 0, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "\\nlegacy swarm enter reminder\\n" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } ] } - [wire] swarm_mode.exit {} + [wire] swarm_mode.enter { "trigger": "manual" } + [wire] context.append_message { "message": { "role": "user", "content": [ { "type": "text", "text": "\\nlegacy swarm enter reminder\\n" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } } + [wire] swarm_mode.exit {} `); }); diff --git a/packages/agent-core-v2/test/wireRecord/index.test.ts b/packages/agent-core-v2/test/wireRecord/index.test.ts index eedc011ad..1e6d702ab 100644 --- a/packages/agent-core-v2/test/wireRecord/index.test.ts +++ b/packages/agent-core-v2/test/wireRecord/index.test.ts @@ -71,13 +71,6 @@ const V1_RECORD_TYPES: ReadonlySet = new Set([ 'mcp.tools_discovered', ]); -/** Registered for replaying old logs only; the live path never dispatches them. */ -const REPLAY_ONLY_TYPES: ReadonlySet = new Set([ - 'turn.launch', - 'todo.set', - 'context.splice', -]); - describe('v1 wire vocabulary', () => { const SCOPE = 'wire'; const KEY = 'v1-vocabulary-test'; @@ -110,7 +103,6 @@ describe('v1 wire vocabulary', () => { it('every persisted op type is a v1 record type', () => { for (const [type, descriptor] of OP_REGISTRY) { if (descriptor.persist === false) continue; - if (REPLAY_ONLY_TYPES.has(type)) continue; expect(V1_RECORD_TYPES.has(type), `op "${type}" persists a non-v1 record type`).toBe(true); } }); @@ -396,16 +388,12 @@ describe('AgentRecords persistence metadata', () => { await ctx.restore([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'restored prompt' }], - toolCalls: [], - }, - ], + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'restored prompt' }], + toolCalls: [], + }, }, { type: 'context_size.measured', @@ -438,7 +426,7 @@ describe('IAgentWireRecordService.records()', () => { it('returns restored records in order, excluding metadata', async () => { const persistence = new InMemoryWireRecordPersistence([ { type: 'metadata', protocol_version: AGENT_WIRE_PROTOCOL_VERSION, created_at: 1 }, - { type: 'context.splice', start: 0, deleteCount: 0, messages: [userMessage('restored')] }, + { type: 'context.append_message', message: userMessage('restored') }, ]); const records = createTestAgent({ persistence, autoConfigure: false }).wireRecord; await records.restore(); @@ -447,7 +435,7 @@ describe('IAgentWireRecordService.records()', () => { const types = snapshot .map((record) => record.type) .filter((type) => type !== 'config.update'); - expect(types).toEqual(['context.splice']); + expect(types).toEqual(['context.append_message']); // A copy is returned, so mutating it must not affect the service. const lengthBefore = records.getRecords().length; (snapshot as unknown as PersistedWireRecord[]).pop(); diff --git a/packages/agent-core-v2/test/wireRecord/resume.test.ts b/packages/agent-core-v2/test/wireRecord/resume.test.ts index c2a7b369b..31d9d3868 100644 --- a/packages/agent-core-v2/test/wireRecord/resume.test.ts +++ b/packages/agent-core-v2/test/wireRecord/resume.test.ts @@ -182,67 +182,51 @@ describe('Agent resume', () => { const persistence = new RecordingAgentPersistence([ resumeConfigRecord(), { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Run lookup' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Run lookup' }], + toolCalls: [], + origin: { kind: 'user' }, + }, }, { - type: 'turn.launch', - turnId: 0, + type: 'turn.prompt', + input: [], origin: { kind: 'user' }, }, { - type: 'context.splice', - start: 1, - deleteCount: 0, - messages: [ - { - role: 'assistant', - content: [], - toolCalls: [ - { - type: 'function', - id: 'call_lookup', - name: 'Lookup', - arguments: JSON.stringify({ query: 'moon' }), - }, - ], - }, - ], + type: 'context.append_message', + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call_lookup', + name: 'Lookup', + arguments: JSON.stringify({ query: 'moon' }), + }, + ], + }, }, { - type: 'context.splice', - start: 2, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Follow-up recorded before result' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Follow-up recorded before result' }], + toolCalls: [], + origin: { kind: 'user' }, + }, }, { - type: 'context.splice', - start: 3, - deleteCount: 0, - messages: [ - { - role: 'tool', - content: [{ type: 'text', text: 'lookup result' }], - toolCalls: [], - toolCallId: 'call_lookup', - }, - ], + type: 'context.append_message', + message: { + role: 'tool', + content: [{ type: 'text', text: 'lookup result' }], + toolCalls: [], + toolCallId: 'call_lookup', + }, }, ] as unknown as PersistedWireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); @@ -597,47 +581,35 @@ describe('Agent resume', () => { it('drops an orphan tool result whose call was never recorded', async () => { const persistence = new RecordingAgentPersistence([ { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Hi' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Hi' }], + toolCalls: [], + origin: { kind: 'user' }, + }, }, { - type: 'turn.launch', - turnId: 0, + type: 'turn.prompt', + input: [], origin: { kind: 'user' }, }, { - type: 'context.splice', - start: 1, - deleteCount: 0, - messages: [ - { - role: 'assistant', - content: [{ type: 'text', text: 'Hello.' }], - toolCalls: [], - }, - ], + type: 'context.append_message', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Hello.' }], + toolCalls: [], + }, }, { - type: 'context.splice', - start: 2, - deleteCount: 0, - messages: [ - { - role: 'tool', - content: [{ type: 'text', text: 'orphaned' }], - toolCalls: [], - toolCallId: 'call_ghost', - }, - ], + type: 'context.append_message', + message: { + role: 'tool', + content: [{ type: 'text', text: 'orphaned' }], + toolCalls: [], + toolCallId: 'call_ghost', + }, }, ] as unknown as PersistedWireRecord[]); const ctx = testAgent({ persistence, autoConfigure: false }); @@ -952,96 +924,76 @@ function resumeDeferredSystemReminderHistory(): PersistedWireRecord[] { return [ resumeConfigRecord(), { - type: 'context.splice', - start: 0, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [{ type: 'text', text: 'Historical prompt before skill' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - ], + type: 'context.append_message', + message: { + role: 'user', + content: [{ type: 'text', text: 'Historical prompt before skill' }], + toolCalls: [], + origin: { kind: 'user' }, + }, }, { - type: 'turn.launch', - turnId: 0, + type: 'turn.prompt', + input: [], origin: { kind: 'user' }, }, { - type: 'context.splice', - start: 1, - deleteCount: 0, - messages: [ - { - role: 'assistant', - content: [], - toolCalls: [ - { - type: 'function', - id: 'call_resume_write', - name: 'Write', - arguments: JSON.stringify({ path: 'result.txt' }), - }, - { - type: 'function', - id: 'call_resume_skill', - name: 'Skill', - arguments: JSON.stringify({ skill: 'review' }), - }, - ], - }, - ], - }, - { - type: 'context.splice', - start: 2, - deleteCount: 0, - messages: [ - { - role: 'tool', - content: [{ type: 'text', text: 'wrote file' }], - toolCalls: [], - toolCallId: 'call_resume_write', - }, - ], - }, - { - type: 'context.splice', - start: 3, - deleteCount: 0, - messages: [ - { - role: 'tool', - content: [{ type: 'text', text: 'skill loaded' }], - toolCalls: [], - toolCallId: 'call_resume_skill', - }, - ], - }, - { - type: 'context.splice', - start: 4, - deleteCount: 0, - messages: [ - { - role: 'user', - content: [ - { - type: 'text', - text: '\nresume skill body\n', - }, - ], - toolCalls: [], - origin: { - kind: 'skill_activation', - activationId: 'act_resume_skill', - skillName: 'review', - trigger: 'model-tool', + type: 'context.append_message', + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'call_resume_write', + name: 'Write', + arguments: JSON.stringify({ path: 'result.txt' }), }, + { + type: 'function', + id: 'call_resume_skill', + name: 'Skill', + arguments: JSON.stringify({ skill: 'review' }), + }, + ], + }, + }, + { + type: 'context.append_message', + message: { + role: 'tool', + content: [{ type: 'text', text: 'wrote file' }], + toolCalls: [], + toolCallId: 'call_resume_write', + }, + }, + { + type: 'context.append_message', + message: { + role: 'tool', + content: [{ type: 'text', text: 'skill loaded' }], + toolCalls: [], + toolCallId: 'call_resume_skill', + }, + }, + { + type: 'context.append_message', + message: { + role: 'user', + content: [ + { + type: 'text', + text: '\nresume skill body\n', + }, + ], + toolCalls: [], + origin: { + kind: 'skill_activation', + activationId: 'act_resume_skill', + skillName: 'review', + trigger: 'model-tool', }, - ], + }, }, ] as unknown as PersistedWireRecord[]; } @@ -1056,31 +1008,30 @@ function resumeConfigRecord(): PersistedWireRecord { } as unknown as PersistedWireRecord; } -function contextSpliceRecord( - start: number, +function contextAppendRecord( + _start: number, messages: readonly { readonly role: 'user' | 'assistant'; readonly text: string; readonly origin?: PromptOrigin; }[], ): PersistedWireRecord { + const message = messages[0]!; return { - type: 'context.splice', - start, - deleteCount: 0, - messages: messages.map((message) => ({ + type: 'context.append_message', + message: { role: message.role, content: [{ type: 'text', text: message.text }], toolCalls: [], origin: message.origin, - })), + }, } as unknown as PersistedWireRecord; } -function turnLaunchRecord(turnId: number, origin: PromptOrigin): PersistedWireRecord { +function turnPromptRecord(_turnId: number, origin: PromptOrigin): PersistedWireRecord { return { - type: 'turn.launch', - turnId, + type: 'turn.prompt', + input: [], origin, } as unknown as PersistedWireRecord; } @@ -1093,9 +1044,9 @@ function canonicalPromptedTurn( ): PersistedWireRecord[] { const origin: PromptOrigin = { kind: 'user' }; return [ - contextSpliceRecord(start, [{ role: 'user', text: promptText, origin }]), - turnLaunchRecord(turnId, origin), - contextSpliceRecord(start + 1, [{ role: 'assistant', text: responseText }]), + contextAppendRecord(start, [{ role: 'user', text: promptText, origin }]), + turnPromptRecord(turnId, origin), + contextAppendRecord(start + 1, [{ role: 'assistant', text: responseText }]), ]; } @@ -1105,8 +1056,8 @@ function canonicalContinuationTurn( start: number, ): PersistedWireRecord[] { return [ - turnLaunchRecord(turnId, { kind: 'system_trigger', name: 'goal_continuation' }), - contextSpliceRecord(start, [{ role: 'assistant', text: responseText }]), + turnPromptRecord(turnId, { kind: 'system_trigger', name: 'goal_continuation' }), + contextAppendRecord(start, [{ role: 'assistant', text: responseText }]), ]; }