mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-07-10 01:39:25 +00:00
refactor(agent-core-v2): drop replay-only wire ops
Remove the three replay-only Ops that were kept for pre-alignment / 1.5 sessions, now that v2 persists natively in the v1 vocabulary: - turn.launch (replaced by turn.prompt) - todo.set (replaced by tools.update_store with key 'todo') - context.splice (replaced by context.append_message / append_loop_event) Also drop the dead code that handled them (transcript reducer, task-origin extraction, blob dehydration, harness helpers) and migrate the affected tests to the v1 record types. The live write path already emitted only v1 records, so wire.jsonl output is unchanged.
This commit is contained in:
parent
ec9dae72ab
commit
c377ab22a4
26 changed files with 253 additions and 680 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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` 等。本文档中涉及这些
|
||||
> 类型的示例与映射,按上述替换理解。
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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': {
|
||||
|
|
|
|||
|
|
@ -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<PersistedRecord> {
|
||||
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<ContextMessage[]>('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[],
|
||||
|
|
|
|||
|
|
@ -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<PersistedRecord>): 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.
|
||||
|
|
|
|||
|
|
@ -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: [] });
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TurnModelState>('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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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<readonly TodoItem[]>;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: '<system-reminder>\nfirst reminder\n</system-reminder>' },
|
||||
]);
|
||||
expect(ctx.project()[6]?.content).toEqual([
|
||||
{ type: 'text', text: '<system-reminder>\nsecond reminder\n</system-reminder>' },
|
||||
]);
|
||||
});
|
||||
|
||||
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: <system-prompt>
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<void>((resolve) => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(`<kimi-skill-loaded name="${skillName}"`)
|
||||
);
|
||||
});
|
||||
});
|
||||
}) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
function isRecordWithMessages(
|
||||
function isRecordWithMessage(
|
||||
record: unknown,
|
||||
): record is {
|
||||
readonly type: string;
|
||||
readonly messages: readonly {
|
||||
readonly message: {
|
||||
readonly content?: readonly { readonly type?: string; readonly text?: string }[];
|
||||
}[];
|
||||
};
|
||||
} {
|
||||
if (record === null || typeof record !== 'object') return false;
|
||||
const candidate = record as { readonly type?: unknown; readonly messages?: unknown };
|
||||
return candidate.type === 'context.splice' && Array.isArray(candidate.messages);
|
||||
const candidate = record as { readonly type?: unknown; readonly message?: unknown };
|
||||
return (
|
||||
candidate.type === 'context.append_message' &&
|
||||
candidate.message !== null &&
|
||||
typeof candidate.message === 'object'
|
||||
);
|
||||
}
|
||||
|
||||
describe('ToolManager SkillTool registration', () => {
|
||||
|
|
@ -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.',
|
||||
'',
|
||||
'<kimi-skill-loaded name="review" trigger="model-tool" source="user" dir="/skills/review" args="">',
|
||||
'body of review',
|
||||
'</kimi-skill-loaded>',
|
||||
].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.',
|
||||
'',
|
||||
'<kimi-skill-loaded name="review" trigger="model-tool" source="user" dir="/skills/review" args="">',
|
||||
'body of review',
|
||||
'</kimi-skill-loaded>',
|
||||
].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({
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
|
|
|
|||
|
|
@ -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": "<system-reminder>\\nlegacy swarm enter reminder\\n</system-reminder>" } ], "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": "<system-reminder>\\nlegacy swarm enter reminder\\n</system-reminder>" } ], "toolCalls": [], "origin": { "kind": "injection", "variant": "swarm_mode" } } }
|
||||
[wire] swarm_mode.exit {}
|
||||
`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -71,13 +71,6 @@ const V1_RECORD_TYPES: ReadonlySet<string> = new Set([
|
|||
'mcp.tools_discovered',
|
||||
]);
|
||||
|
||||
/** Registered for replaying old logs only; the live path never dispatches them. */
|
||||
const REPLAY_ONLY_TYPES: ReadonlySet<string> = 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();
|
||||
|
|
|
|||
|
|
@ -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: '<system-reminder>\nresume skill body\n</system-reminder>',
|
||||
},
|
||||
],
|
||||
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: '<system-reminder>\nresume skill body\n</system-reminder>',
|
||||
},
|
||||
],
|
||||
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 }]),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue