| /** |
| * Tests for runtime-event-adapters and model-history projection. |
| * |
| * Run: `npm --workspace @maka/runtime run test` |
| * |
| * Proves the policy from the work node body: |
| * - partial model chunks are not included in durable model history; |
| * - tool/function response events can be included when model-visible; |
| * - diagnostics/token/permission-only events are excluded; |
| * - legacy user/assistant/system stored messages convert safely. |
| */ |
| |
| import { describe, test } from 'node:test'; |
| import { expect } from '../test-helpers.js'; |
| import type { AttachmentRef } from '@maka/core'; |
| import type { |
| UserMessage, |
| AssistantMessage, |
| SystemNoteMessage, |
| ToolCallMessage, |
| ToolResultMessage, |
| TokenUsageMessage, |
| PermissionDecisionMessage, |
| TurnStateMessage, |
| StoredMessage, |
| } from '@maka/core'; |
| import type { RuntimeEvent, RuntimeEventContent } from '@maka/core'; |
| import { |
| storedMessageToRuntimeEvent, |
| storedMessageToRuntimeEvents, |
| runtimeEventToStoredMessageDraft, |
| } from '../runtime-event-adapters.js'; |
| import { |
| buildModelHistoryFromRuntimeEvents, |
| buildRuntimeEventModelReplayPlan, |
| buildSteeringEnvelope, |
| buildTextModelMessagesFromRuntimeEvents, |
| collectToolActivityTurnIds, |
| steeringMessagesMissingFromBase, |
| steeringModelMessage, |
| steeringProviderOptions, |
| } from '../model-history.js'; |
| |
| // ---------- StoredMessage fixtures ---------- |
| |
| const ts = 1_700_000_000_000; |
| const turnId = 't1'; |
| |
| const attachment: AttachmentRef = { |
| kind: 'pdf', |
| name: 'brief.pdf', |
| mimeType: 'application/pdf', |
| bytes: 2048, |
| ref: { kind: 'session_file', sessionId: 'sess-1', relativePath: 'attachments/brief.pdf' }, |
| }; |
| |
| const user = (id: string, text: string): UserMessage => ({ |
| type: 'user', |
| id, |
| turnId, |
| ts: ts + 1, |
| text, |
| }); |
| |
| const assistant = ( |
| id: string, |
| text: string, |
| thinking?: AssistantMessage['thinking'], |
| ): AssistantMessage => ({ |
| type: 'assistant', |
| id, |
| turnId, |
| ts: ts + 2, |
| text, |
| modelId: 'claude-sonnet-4-5', |
| ...(thinking ? { thinking } : {}), |
| }); |
| |
| const note = (id: string, kind: SystemNoteMessage['kind']): SystemNoteMessage => ({ |
| type: 'system_note', |
| id, |
| ts: ts + 3, |
| kind, |
| }); |
| |
| const toolCall = (id: string, name: string, args: unknown = {}): ToolCallMessage => ({ |
| type: 'tool_call', |
| id, |
| turnId, |
| ts: ts + 4, |
| toolName: name, |
| args, |
| }); |
| |
| const toolResult = (toolUseId: string, isError: boolean, text: string): ToolResultMessage => ({ |
| type: 'tool_result', |
| id: `r-${toolUseId}`, |
| turnId, |
| ts: ts + 5, |
| toolUseId, |
| isError, |
| content: { kind: 'text', text }, |
| }); |
| |
| const tokens = (id: string): TokenUsageMessage => ({ |
| type: 'token_usage', |
| id, |
| turnId, |
| ts: ts + 6, |
| input: 10, |
| output: 5, |
| }); |
| |
| const permission = (id: string): PermissionDecisionMessage => ({ |
| type: 'permission_decision', |
| id, |
| turnId, |
| ts: ts + 7, |
| toolUseId: 'tu-1', |
| toolName: 'Write', |
| decision: 'allow', |
| }); |
| |
| const turnState = (id: string): TurnStateMessage => ({ |
| type: 'turn_state', |
| id, |
| turnId, |
| ts: ts + 8, |
| status: 'completed', |
| partialOutputRetained: false, |
| }); |
| |
| const ctx = { |
| sessionId: 'sess-1', |
| invocationId: 'inv-1', |
| runId: 'run-1', |
| }; |
| |
| // ---------- RuntimeEvent fixtures ---------- |
| |
| let __seq = 0; |
| function ev( |
| overrides: Partial<RuntimeEvent> & { content?: RuntimeEventContent } = {}, |
| ): RuntimeEvent { |
| __seq += 1; |
| return { |
| id: `evt-${__seq}`, |
| invocationId: 'inv-1', |
| runId: 'run-1', |
| sessionId: 'sess-1', |
| turnId: 'turn-1', |
| ts: ts + __seq, |
| partial: false, |
| role: 'user', |
| author: 'user', |
| ...overrides, |
| }; |
| } |
| |
| // ============================================================================ |
| // storedMessageToRuntimeEvent (singular) |
| // ============================================================================ |
| |
| describe('storedMessageToRuntimeEvent', () => { |
| test('user message → role user, text content, refs link', () => { |
| const e = storedMessageToRuntimeEvent(user('u1', 'hello'), ctx); |
| expect(e).not.toBeNull(); |
| if (!e) return; |
| expect(e.role).toBe('user'); |
| expect(e.author).toBe('user'); |
| expect(e.partial).toBe(false); |
| expect(e.content).toEqual({ kind: 'text', text: 'hello' }); |
| expect(e.refs?.storedMessageId).toBe('u1'); |
| expect(e.sessionId).toBe('sess-1'); |
| expect(e.turnId).toBe(turnId); |
| expect(e.ts).toBe(ts + 1); |
| }); |
| |
| test('user message with attachments preserves attachment refs in text content', () => { |
| const e = storedMessageToRuntimeEvent( |
| { ...user('u-attach', 'see attached'), attachments: [attachment] }, |
| ctx, |
| ); |
| expect(e).not.toBeNull(); |
| if (!e) return; |
| expect(e.content).toEqual({ |
| kind: 'text', |
| text: 'see attached', |
| attachments: [attachment], |
| }); |
| }); |
| |
| test('host-authored user-role messages preserve their canonical provenance', () => { |
| const e = storedMessageToRuntimeEvent( |
| { |
| ...user('u-graph', 'graph checkpoint'), |
| origin: { |
| kind: 'agent_graph', |
| graphId: 'graph-1', |
| wakeId: 'wake-1', |
| attemptId: 'attempt-1', |
| }, |
| }, |
| ctx, |
| ); |
| expect(e?.role).toBe('user'); |
| expect(e?.author).toBe('host'); |
| expect(e?.content).toMatchObject({ |
| kind: 'text', |
| origin: { |
| kind: 'agent_graph', |
| graphId: 'graph-1', |
| wakeId: 'wake-1', |
| attemptId: 'attempt-1', |
| }, |
| }); |
| }); |
| |
| test('user message displayText round-trips through RuntimeEvent draft projection', () => { |
| const typed = '/skill:alpha 帮我整理'; |
| const envelope = 'The user explicitly invoked…\n\n<user-message>\n帮我整理\n</user-message>'; |
| const inlineReferences = [ |
| { kind: 'skill' as const, value: '/skill:alpha', label: 'Alpha', start: 0 }, |
| ]; |
| const e = storedMessageToRuntimeEvent( |
| { ...user('u-skill', envelope), displayText: typed, inlineReferences }, |
| ctx, |
| ); |
| expect(e).not.toBeNull(); |
| if (!e) return; |
| expect(e.content).toEqual({ |
| kind: 'text', |
| text: envelope, |
| displayText: typed, |
| inlineReferences, |
| }); |
| const draft = runtimeEventToStoredMessageDraft(e); |
| expect(draft).toMatchObject({ |
| type: 'user', |
| text: envelope, |
| displayText: typed, |
| inlineReferences, |
| }); |
| }); |
| |
| test('an explicit empty reference projection survives the RuntimeEvent round trip', () => { |
| const event = storedMessageToRuntimeEvent( |
| { ...user('u-plain', 'plain'), inlineReferences: [] }, |
| ctx, |
| ); |
| expect(event?.content).toMatchObject({ inlineReferences: [] }); |
| if (!event) throw new Error('expected user RuntimeEvent'); |
| expect(runtimeEventToStoredMessageDraft(event)).toMatchObject({ |
| type: 'user', |
| inlineReferences: [], |
| }); |
| }); |
| |
| test('assistant message (text only) → role model, text content; thinking dropped', () => { |
| const e = storedMessageToRuntimeEvent(assistant('a1', 'hi'), ctx); |
| if (!e) throw new Error('expected event'); |
| expect(e.role).toBe('model'); |
| expect(e.author).toBe('agent'); |
| expect(e.content).toEqual({ kind: 'text', text: 'hi' }); |
| }); |
| |
| test('system_note → role system, text content labels the note kind', () => { |
| const e = storedMessageToRuntimeEvent(note('n1', 'session_start'), ctx); |
| if (!e) throw new Error('expected event'); |
| expect(e.role).toBe('system'); |
| expect(e.author).toBe('system'); |
| expect(e.content).toEqual({ kind: 'text', text: 'system_note:session_start' }); |
| }); |
| |
| test('tool_call → null (needs runtime-runner-owned mapping)', () => { |
| expect(storedMessageToRuntimeEvent(toolCall('tc1', 'Read'), ctx)).toBeNull(); |
| }); |
| |
| test('session-level note without turnId defaults to empty string', () => { |
| const e = storedMessageToRuntimeEvent(note('n', 'session_resume'), ctx); |
| if (!e) throw new Error('expected event'); |
| expect(e.turnId).toBe(''); |
| }); |
| |
| test('custom newId is used for generated event ids', () => { |
| const e = storedMessageToRuntimeEvent(user('u', 'x'), { |
| ...ctx, |
| newId: () => 'fixed-id', |
| }); |
| if (!e) throw new Error('expected event'); |
| expect(e.id).toBe('fixed-id'); |
| }); |
| }); |
| |
| // ============================================================================ |
| // storedMessageToRuntimeEvents (plural — captures thinking) |
| // ============================================================================ |
| |
| describe('storedMessageToRuntimeEvents', () => { |
| test('assistant with thinking → [text event, thinking event]', () => { |
| const out = storedMessageToRuntimeEvents( |
| assistant('a2', 'answer', { text: 'reasoning', signature: 'sig-1' }), |
| ctx, |
| ); |
| expect(out).toHaveLength(2); |
| expect(out[0]?.content).toEqual({ kind: 'text', text: 'answer' }); |
| expect(out[1]?.content).toEqual({ |
| kind: 'thinking', |
| text: 'reasoning', |
| signature: 'sig-1', |
| }); |
| expect(out[1]?.role).toBe('model'); |
| expect(out[1]?.refs?.storedMessageId).toBe('a2'); |
| }); |
| |
| test('assistant with multiple reasoning parts → one replay event per part', () => { |
| const out = storedMessageToRuntimeEvents( |
| assistant('a-parts', 'answer', { |
| text: 'firstsecond', |
| parts: [ |
| { |
| text: 'first', |
| providerOptions: { |
| openai: { itemId: 'rs_first', reasoningEncryptedContent: 'encrypted-first' }, |
| }, |
| }, |
| { |
| text: 'second', |
| providerOptions: { |
| openai: { itemId: 'rs_second', reasoningEncryptedContent: 'encrypted-second' }, |
| }, |
| }, |
| ], |
| }), |
| ctx, |
| ); |
| |
| expect(out.map((event) => event.content)).toEqual([ |
| { kind: 'text', text: 'answer' }, |
| { |
| kind: 'thinking', |
| text: 'first', |
| providerOptions: { |
| openai: { itemId: 'rs_first', reasoningEncryptedContent: 'encrypted-first' }, |
| }, |
| }, |
| { |
| kind: 'thinking', |
| text: 'second', |
| providerOptions: { |
| openai: { itemId: 'rs_second', reasoningEncryptedContent: 'encrypted-second' }, |
| }, |
| }, |
| ]); |
| }); |
| |
| test('assistant with explicit empty thinking preserves provider replay metadata', () => { |
| const out = storedMessageToRuntimeEvents( |
| assistant('a3', 'hi', { |
| text: '', |
| providerOptions: { maka: { kimiReasoningField: 'reasoning' } }, |
| }), |
| ctx, |
| ); |
| expect(out).toHaveLength(2); |
| expect(out[1]?.content).toEqual({ |
| kind: 'thinking', |
| text: '', |
| providerOptions: { maka: { kimiReasoningField: 'reasoning' } }, |
| }); |
| }); |
| }); |
| |
| // ============================================================================ |
| // runtimeEventToStoredMessageDraft |
| // ============================================================================ |
| |
| describe('runtimeEventToStoredMessageDraft', () => { |
| test('user text event → UserMessage', () => { |
| const event = ev({ |
| role: 'user', |
| author: 'user', |
| content: { kind: 'text', text: 'hello' }, |
| refs: { storedMessageId: 'u1' }, |
| }); |
| const draft = runtimeEventToStoredMessageDraft(event); |
| expect(draft).not.toBeNull(); |
| if (!draft) return; |
| expect(draft.type).toBe('user'); |
| if (draft.type !== 'user') return; |
| expect(draft.id).toBe('u1'); |
| expect(draft.text).toBe('hello'); |
| expect(draft.turnId).toBe(event.turnId); |
| expect(draft.ts).toBe(event.ts); |
| }); |
| |
| test('user text event with attachments → UserMessage with attachments', () => { |
| const event = ev({ |
| role: 'user', |
| author: 'user', |
| content: { kind: 'text', text: 'see attached', attachments: [attachment] }, |
| refs: { storedMessageId: 'u-attach' }, |
| }); |
| const draft = runtimeEventToStoredMessageDraft(event); |
| expect(draft).not.toBeNull(); |
| if (!draft || draft.type !== 'user') return; |
| expect(draft.attachments).toEqual([attachment]); |
| }); |
| |
| test('model text event with modelId → AssistantMessage', () => { |
| const event = ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'answer' }, |
| refs: { storedMessageId: 'a1' }, |
| }); |
| const draft = runtimeEventToStoredMessageDraft(event, { modelId: 'gpt-4o' }); |
| expect(draft).not.toBeNull(); |
| if (!draft) return; |
| expect(draft.type).toBe('assistant'); |
| if (draft.type !== 'assistant') return; |
| expect(draft.id).toBe('a1'); |
| expect(draft.text).toBe('answer'); |
| expect(draft.modelId).toBe('gpt-4o'); |
| }); |
| |
| test('model text event without modelId → null (no safe legacy shape)', () => { |
| const event = ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'answer' }, |
| }); |
| expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); |
| }); |
| |
| test('partial user and model text events → null', () => { |
| const partialUser = ev({ |
| partial: true, |
| role: 'user', |
| author: 'user', |
| content: { kind: 'text', text: 'typing...' }, |
| }); |
| const partialModel = ev({ |
| partial: true, |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'streaming...' }, |
| }); |
| |
| expect(runtimeEventToStoredMessageDraft(partialUser)).toBeNull(); |
| expect(runtimeEventToStoredMessageDraft(partialModel, { modelId: 'gpt-4o' })).toBeNull(); |
| }); |
| |
| test('thinking event → null', () => { |
| const event = ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'hmm' }, |
| }); |
| expect(runtimeEventToStoredMessageDraft(event, { modelId: 'm' })).toBeNull(); |
| }); |
| |
| test('function_call event → null (tool projection owned elsewhere)', () => { |
| const event = ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, |
| }); |
| expect(runtimeEventToStoredMessageDraft(event)).toBeNull(); |
| }); |
| }); |
| |
| // ============================================================================ |
| // buildModelHistoryFromRuntimeEvents — policy |
| // ============================================================================ |
| |
| describe('buildModelHistoryFromRuntimeEvents', () => { |
| test('POLICY: function_response with isError stays model-visible', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'fc1', |
| name: 'Write', |
| result: 'denied', |
| isError: true, |
| }, |
| }), |
| ]; |
| const out = buildModelHistoryFromRuntimeEvents(events); |
| expect(out).toHaveLength(1); |
| expect((out[0].content as { isError?: boolean }).isError).toBe(true); |
| }); |
| |
| test('POLICY: tool events excluded when includeToolEvents=false (text-only replay)', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'fc1', |
| name: 'Read', |
| result: 'data', |
| }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), |
| ]; |
| const out = buildModelHistoryFromRuntimeEvents(events, { |
| includeToolEvents: false, |
| }); |
| expect(out).toHaveLength(2); |
| expect(out.map((e) => e.role)).toEqual(['user', 'model']); |
| }); |
| |
| test('POLICY: system-role event included when includeSystemEvents=true', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'system', |
| author: 'system', |
| content: { kind: 'text', text: 'You are a helpful assistant.' }, |
| }), |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), |
| ]; |
| const out = buildModelHistoryFromRuntimeEvents(events, { |
| includeSystemEvents: true, |
| }); |
| expect(out).toHaveLength(2); |
| expect(out[0]?.role).toBe('system'); |
| }); |
| |
| test('POLICY: thinking excluded by default, included when includeThinking=true', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'reasoning', signature: 's' }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'a' } }), |
| ]; |
| expect(buildModelHistoryFromRuntimeEvents(events)).toHaveLength(1); |
| const out = buildModelHistoryFromRuntimeEvents(events, { |
| includeThinking: true, |
| }); |
| expect(out).toHaveLength(2); |
| expect(out[0]?.content?.kind).toBe('thinking'); |
| }); |
| |
| test('entries preserve event order and carry eventId + ts', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'a' } }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'b' } }), |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'c' } }), |
| ]; |
| const out = buildModelHistoryFromRuntimeEvents(events); |
| expect(out.map((e) => e.eventId)).toEqual(events.map((e) => e.id)); |
| expect(out.map((e) => e.ts)).toEqual(events.map((e) => e.ts)); |
| }); |
| |
| test('full durable-history-shaped stream: partials + finals + diagnostics', () => { |
| // Mirrors a realistic turn: streaming chunks (partial), final assistant |
| // text, tool call/response, token usage, system note, terminal marker. |
| const events: RuntimeEvent[] = [ |
| ev({ |
| partial: true, |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'Let me ' }, |
| }), |
| ev({ |
| partial: true, |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'Let me check' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'fc1', |
| name: 'Read', |
| args: { path: '/a' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'fc1', |
| name: 'Read', |
| result: 'contents', |
| }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'Here is the file.' }, |
| }), |
| ev({ |
| role: 'system', |
| author: 'system', |
| actions: { tokenUsage: { input: 10, output: 5 } }, |
| }), |
| ev({ |
| role: 'system', |
| author: 'system', |
| content: { kind: 'text', text: 'system_note:mode_change' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| status: 'completed', |
| actions: { endInvocation: true }, |
| }), |
| ]; |
| const out = buildModelHistoryFromRuntimeEvents(events); |
| // Only: function_call, function_response, final text. |
| expect(out.map((e) => e.content?.kind)).toEqual(['function_call', 'function_response', 'text']); |
| }); |
| |
| test('text-only AI SDK projection skips unsupported entries and describes user attachments', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'user', |
| author: 'user', |
| content: { kind: 'text', text: 'see attached', attachments: [attachment] }, |
| }), |
| ev({ |
| partial: true, |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'partial' }, |
| }), |
| ev({ |
| role: 'system', |
| author: 'system', |
| content: { kind: 'text', text: 'system note' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'private reasoning' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'function_call', id: 'fc1', name: 'Read', args: {} }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { kind: 'function_response', id: 'fc1', name: 'Read', result: 'data' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'text', text: 'final answer' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| status: 'completed', |
| actions: { endInvocation: true }, |
| }), |
| ]; |
| |
| expect(buildTextModelMessagesFromRuntimeEvents(events)).toEqual([ |
| { |
| role: 'user', |
| content: |
| 'see attached\n\n<attachment>\nThe attachment content is unavailable to Read.\nname: "brief.pdf"\nmime_type: "application/pdf"\n</attachment>', |
| }, |
| { role: 'assistant', content: 'final answer' }, |
| ]); |
| }); |
| |
| test('text-only projections keep the steering structured identity', () => { |
| // Round-6 R4: the text-only projections (plan.textMessages and |
| // buildTextModelMessagesFromRuntimeEvents) must carry the same |
| // providerOptions steering marker as the full replay — a base that drops |
| // the event id makes id-based dedupe blind, and a live injection of the |
| // same message doubles it. |
| const steered = ev({ |
| role: 'user', |
| author: 'user', |
| content: { kind: 'text', text: 'steer it', steering: true }, |
| }); |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'ask' } }), |
| steered, |
| ]; |
| |
| const textMessages = buildTextModelMessagesFromRuntimeEvents(events); |
| expect(textMessages).toEqual([ |
| { role: 'user', content: 'ask' }, |
| { |
| role: 'user', |
| content: buildSteeringEnvelope('steer it'), |
| providerOptions: steeringProviderOptions(steered.id), |
| }, |
| ]); |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| expect(plan.textMessages.at(-1)).toEqual({ |
| role: 'user', |
| content: buildSteeringEnvelope('steer it'), |
| providerOptions: steeringProviderOptions(steered.id), |
| }); |
| |
| // Id-based dedupe holds when this projection is the request base. |
| const injected = steeringModelMessage(steered.id, buildSteeringEnvelope('steer it')); |
| expect(steeringMessagesMissingFromBase([injected], textMessages)).toEqual([]); |
| expect(steeringMessagesMissingFromBase([injected], plan.textMessages)).toEqual([]); |
| }); |
| |
| test('runtime replay plan preserves structured tool calls and results', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'read package' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Read', |
| args: { path: 'package.json' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Read', |
| result: { ok: true, text: 'contents' }, |
| isError: false, |
| }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.hasProviderNativeSemantics).toBe(true); |
| expect(plan.semanticKinds).toEqual(['text', 'tool_call', 'tool_result']); |
| expect(plan.items).toEqual([ |
| { |
| kind: 'text', |
| role: 'user', |
| content: 'read package', |
| eventId: events[0]?.id, |
| ts: events[0]?.ts, |
| }, |
| { |
| kind: 'tool_call', |
| toolCallId: 'tool-1', |
| toolName: 'Read', |
| input: { path: 'package.json' }, |
| eventId: events[1]?.id, |
| ts: events[1]?.ts, |
| }, |
| { |
| kind: 'tool_result', |
| toolCallId: 'tool-1', |
| toolName: 'Read', |
| output: { ok: true, text: 'contents' }, |
| isError: false, |
| eventId: events[2]?.id, |
| ts: events[2]?.ts, |
| }, |
| ]); |
| }); |
| |
| test('runtime replay skips hidden nested tool activity without blocking fallback', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'inspect' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| origin: 'code_mode', |
| modelVisibility: 'hidden', |
| content: { kind: 'function_call', id: 'nested-1', name: 'Read', args: {} }, |
| refs: { parentToolCallId: 'exec-1', parentOperationId: 'exec-op-1' }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| origin: 'code_mode', |
| modelVisibility: 'hidden', |
| content: { |
| kind: 'function_response', |
| id: 'nested-1', |
| name: 'Read', |
| result: 'ok', |
| }, |
| refs: { parentToolCallId: 'exec-1', parentOperationId: 'exec-op-1' }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| const codes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| |
| expect(plan.items.map((item) => item.kind)).toEqual(['text']); |
| expect(codes).toEqual(['model_hidden_skipped', 'model_hidden_skipped']); |
| expect(codes).not.toContain('unsupported_content'); |
| expect(collectToolActivityTurnIds(events).size).toBe(0); |
| }); |
| |
| test('runtime replay plan normalizes a legacy Bash result without changing durable input', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Bash', |
| args: { command: 'printf ok' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Bash', |
| result: { |
| kind: 'terminal', |
| cwd: '/tmp/work', |
| cmd: 'printf ok', |
| status: 'completed', |
| exitCode: 0, |
| stdout: 'ok', |
| stderr: '', |
| stdoutTruncated: false, |
| stderrTruncated: false, |
| }, |
| isError: false, |
| }, |
| }), |
| ]; |
| |
| const result = buildRuntimeEventModelReplayPlan(events).items.find( |
| (item) => item.kind === 'tool_result', |
| ); |
| expect(result?.kind === 'tool_result' ? result.output : undefined).toEqual({ |
| kind: 'terminal', |
| cwd: '/tmp/work', |
| status: 'completed', |
| exitCode: 0, |
| output: { |
| mode: 'pipes', |
| stdout: 'ok', |
| stderr: '', |
| stdoutTruncated: false, |
| stderrTruncated: false, |
| redacted: false, |
| }, |
| }); |
| expect( |
| events[1]?.content?.kind === 'function_response' |
| ? (events[1].content.result as { cmd?: unknown }).cmd |
| : undefined, |
| ).toBe('printf ok'); |
| }); |
| |
| test('runtime replay plan rejects a mixed legacy/current shell result', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Bash', |
| args: { command: 'printf bad' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Bash', |
| result: { |
| kind: 'terminal', |
| cwd: '/tmp/work', |
| cmd: 'printf bad', |
| status: 'completed', |
| exitCode: 0, |
| stdout: 'bad', |
| stderr: '', |
| stdoutTruncated: false, |
| stderrTruncated: false, |
| output: { |
| mode: 'pipes', |
| stdout: 'bad', |
| stderr: '', |
| stdoutTruncated: false, |
| stderrTruncated: false, |
| redacted: false, |
| }, |
| }, |
| isError: false, |
| }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| expect(plan.items.some((item) => item.kind === 'tool_result')).toBe(false); |
| expect(plan.items.some((item) => item.kind === 'tool_call')).toBe(false); |
| expect(plan.hasProviderNativeSemantics).toBe(false); |
| expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain('unsupported_content'); |
| }); |
| |
| test('runtime replay plan carries thinking separately and text replay never leaks it', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'private reasoning', signature: 'sig-1' }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'public answer' } }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.items.map((item) => item.kind)).toEqual(['thinking', 'text']); |
| expect(plan.textMessages).toEqual([{ role: 'assistant', content: 'public answer' }]); |
| expect(buildTextModelMessagesFromRuntimeEvents(events)).toEqual([ |
| { role: 'assistant', content: 'public answer' }, |
| ]); |
| }); |
| |
| test('runtime replay plan preserves unsigned thinking without flattening it into text', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'private reasoning' }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'answer' } }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.diagnostics).toEqual([]); |
| expect(plan.items.map((item) => item.kind)).toEqual(['thinking', 'text']); |
| const thinking = plan.items.find((item) => item.kind === 'thinking'); |
| expect(thinking && thinking.kind === 'thinking' ? thinking.signature : undefined).toBe( |
| undefined, |
| ); |
| expect(plan.semanticKinds).toContain('thinking'); |
| expect(plan.textMessages).toEqual([{ role: 'assistant', content: 'answer' }]); |
| }); |
| |
| test('unsigned thinking does not downgrade native tool replay for the rest of the history', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'do it' } }), |
| // Non-Anthropic reasoning: thinking persisted with no signature. |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'reason without a signature' }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Read', |
| args: { path: 'package.json' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Read', |
| result: 'contents', |
| isError: false, |
| }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| // The provider-agnostic plan keeps every native semantic item. The target |
| // model adapter decides whether unsigned thinking can be materialized. |
| expect(plan.hasProviderNativeSemantics).toBe(true); |
| expect(plan.items.map((item) => item.kind)).toEqual([ |
| 'text', |
| 'thinking', |
| 'tool_call', |
| 'tool_result', |
| ]); |
| expect(plan.semanticKinds).toContain('thinking'); |
| const codes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| expect(codes).not.toContain('unsupported_role'); |
| expect(codes).not.toContain('unsupported_content'); |
| expect(codes).not.toContain('unmatched_tool_result'); |
| expect(codes).not.toContain('tool_id_mismatch'); |
| }); |
| |
| test('signed thinking still enters native replay items with its signature', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'thinking', text: 'signed reasoning', signature: 'sig-9' }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'answer' } }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.items.map((item) => item.kind)).toEqual(['thinking', 'text']); |
| const thinking = plan.items.find((item) => item.kind === 'thinking'); |
| expect(thinking && thinking.kind === 'thinking' ? thinking.signature : undefined).toBe('sig-9'); |
| expect(plan.semanticKinds).toContain('thinking'); |
| const pureCodes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| // Boundary: the tool-turn skip must NOT swallow a pure-reasoning turn. |
| expect(pureCodes).not.toContain('signed_thinking_in_tool_turn_skipped'); |
| }); |
| |
| test('signed thinking in a tool-calling turn is skipped from replay, tool calls stay native', () => { |
| // Anthropic tool turn as the backend emits it: the turn's reasoning is a |
| // single end-of-turn thinking_complete pushed AFTER the tool events, so the |
| // signed thinking lands last in ledger order. Materialization can only |
| // render it as a standalone assistant reasoning message; after the tool |
| // result that drops the leading thinking block Anthropic requires on the |
| // tool-use assistant message AND leaves an orphan thinking block — a 400. |
| // Skip it from replay (it stays in the read-model for the UI). Removing the |
| // skip re-adds a 'thinking' item after 'tool_result' and fails this test. |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'read package' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Read', |
| args: { path: 'package.json' }, |
| }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Read', |
| result: 'contents', |
| isError: false, |
| }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'thinking', |
| text: 'signed reasoning about the tool result', |
| signature: 'sig-tool', |
| }, |
| }), |
| ev({ role: 'model', author: 'agent', content: { kind: 'text', text: 'here is the answer' } }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.items.map((item) => item.kind)).toEqual([ |
| 'text', |
| 'tool_call', |
| 'tool_result', |
| 'text', |
| ]); |
| expect(plan.semanticKinds).not.toContain('thinking'); |
| expect(plan.hasProviderNativeSemantics).toBe(true); |
| const codes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| expect(codes).toContain('signed_thinking_in_tool_turn_skipped'); |
| // Non-blocking: the tool call/result still replay provider-native. |
| expect(codes).not.toContain('unsupported_role'); |
| expect(codes).not.toContain('unsupported_content'); |
| expect(codes).not.toContain('unmatched_tool_result'); |
| expect(codes).not.toContain('tool_id_mismatch'); |
| }); |
| |
| test('a budget/search slice still skips a tool-turn signed thinking via full-ledger tool ids', () => { |
| // Full prior ledger: a tool turn (tool_call, tool_result, signed thinking). |
| const fullLedger: RuntimeEvent[] = [ |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'function_call', id: 'tool-1', name: 'Read', args: {} }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Read', |
| result: 'x', |
| isError: false, |
| }, |
| }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'thinking', |
| text: 'reasoning about the tool result', |
| signature: 'sig-slice', |
| }, |
| }), |
| ]; |
| // history-search / budget pruning kept ONLY the query-matched signed |
| // thinking; the same turn's tool_call/tool_result were dropped from replay. |
| const slice = [fullLedger[2]!]; |
| |
| // Scanning only the slice looks like a pure-reasoning turn — the latent hole |
| // the full-ledger ids close: the thinking would otherwise replay native. |
| const naive = buildRuntimeEventModelReplayPlan(slice); |
| expect(naive.items.map((item) => item.kind)).toEqual(['thinking']); |
| |
| // Seeding the tool-turn ids from the full ledger restores the skip. |
| const plan = buildRuntimeEventModelReplayPlan(slice, { |
| toolActivityTurnIds: collectToolActivityTurnIds(fullLedger), |
| }); |
| expect(plan.items.map((item) => item.kind)).toEqual([]); |
| expect(plan.semanticKinds).not.toContain('thinking'); |
| expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain( |
| 'signed_thinking_in_tool_turn_skipped', |
| ); |
| }); |
| |
| test('terminal RuntimeEvents are diagnostic-only for replay semantics', () => { |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| status: 'completed', |
| actions: { endInvocation: true }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.items).toHaveLength(1); |
| expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toContain( |
| 'terminal_fact_diagnostic_only', |
| ); |
| }); |
| |
| test('error-content RuntimeEvents are diagnostic-only, never blocking', () => { |
| // A run that errored (or was recovered after an app restart) lands error |
| // events in the ledger: a non-terminal error fact from the flow, and a |
| // terminal commit carrying the failure as error content. Neither is model |
| // conversation — flagging them `unsupported_content` (a blocking |
| // diagnostic) would degrade every later turn of the session to the |
| // stored-message projection. |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { kind: 'function_call', id: 'tool-1', name: 'Bash', args: { command: 'ls' } }, |
| }), |
| ev({ |
| role: 'tool', |
| author: 'tool', |
| content: { |
| kind: 'function_response', |
| id: 'tool-1', |
| name: 'Bash', |
| result: { ok: true }, |
| isError: false, |
| }, |
| }), |
| // Non-terminal error fact (ai-sdk-flow: the terminal complete follows). |
| ev({ |
| role: 'system', |
| author: 'system', |
| content: { kind: 'error', code: 'api_error', reason: 'api_error', message: 'boom' }, |
| }), |
| // Terminal recovery commit (terminal-run-commit after an app restart). |
| ev({ |
| role: 'system', |
| author: 'system', |
| status: 'failed', |
| content: { |
| kind: 'error', |
| code: 'app_restarted', |
| reason: 'app_restarted', |
| message: 'app_restarted', |
| }, |
| actions: { endInvocation: true }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| const codes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| expect(codes).not.toContain('unsupported_content'); |
| expect(codes.filter((code) => code === 'error_content_diagnostic_only')).toHaveLength(2); |
| expect(plan.items.map((item) => item.kind)).toEqual(['text', 'tool_call', 'tool_result']); |
| expect(plan.hasProviderNativeSemantics).toBe(true); |
| }); |
| |
| test('drops a tool call whose result never landed in the ledger', () => { |
| // A crash during tool execution persists the function_call but never its |
| // function_response (recovery then appends a terminal error event). A |
| // replayed tool_use with no tool_result is a provider 400, so the planner |
| // must drop the dangling call — mirroring the deliberately non-blocking |
| // unmatched_tool_result handling — instead of replaying or blocking. |
| const events: RuntimeEvent[] = [ |
| ev({ role: 'user', author: 'user', content: { kind: 'text', text: 'q' } }), |
| ev({ |
| role: 'model', |
| author: 'agent', |
| content: { |
| kind: 'function_call', |
| id: 'tool-1', |
| name: 'Bash', |
| args: { command: 'sleep 999' }, |
| }, |
| }), |
| ev({ |
| role: 'system', |
| author: 'system', |
| status: 'failed', |
| content: { |
| kind: 'error', |
| code: 'app_restarted', |
| reason: 'app_restarted', |
| message: 'app_restarted', |
| }, |
| actions: { endInvocation: true }, |
| }), |
| ]; |
| |
| const plan = buildRuntimeEventModelReplayPlan(events); |
| |
| expect(plan.items.map((item) => item.kind)).toEqual(['text']); |
| expect(plan.semanticKinds).not.toContain('tool_call'); |
| expect(plan.hasProviderNativeSemantics).toBe(false); |
| const codes = plan.diagnostics.map((diagnostic) => diagnostic.code); |
| expect(codes).toContain('unmatched_tool_call'); |
| expect(codes).not.toContain('unsupported_content'); |
| }); |
| }); |
| |
| // ============================================================================ |
| // Adapter + projection integration |
| // ============================================================================ |
| |
| describe('adapter → projection integration', () => { |
| test('legacy messages convert to events then project to clean history', () => { |
| const messages: StoredMessage[] = [ |
| user('u1', 'what is 2+2?'), |
| assistant('a1', 'it is 4'), |
| tokens('tu1'), |
| note('n1', 'mode_change'), |
| ]; |
| const events: RuntimeEvent[] = []; |
| for (const m of messages) { |
| events.push(...storedMessageToRuntimeEvents(m, ctx)); |
| } |
| // Only user + assistant text survive projection (system note excluded, |
| // token_usage never produced an event). |
| const history = buildModelHistoryFromRuntimeEvents(events); |
| expect(history).toHaveLength(2); |
| expect(history[0]?.role).toBe('user'); |
| expect(history[1]?.role).toBe('model'); |
| expect((history[0].content as { text: string }).text).toBe('what is 2+2?'); |
| }); |
| }); |