import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { describe, expect, it } from 'vitest'; import type { ConversationPatch, ConversationPatchEnvelope, ConversationSnapshot, } from '../../electron/coding-runtime/contracts'; import { createConversationReducerState, isConversationSnapshot, reduceConversationPatch, replaceConversationSnapshot, type ConversationReducerState, } from '../../electron/coding-runtime/conversation-reducer'; import { CodingRuntimeContractError, InMemoryConversationRuntime, } from '../../electron/coding-runtime/in-memory-conversation-runtime'; import { createProductSnapshot, MIXED_PRODUCT_PATCHES, PI_PRODUCT_FIXTURE_SOURCE, PI_WIRE_TO_PRODUCT_BOUNDARY, } from '../fixtures/coding-conversation-product-fixtures'; import { productToolDetails } from '../../shared/coding-conversation-product-tool-protocol'; function reduceAll( snapshot: ConversationSnapshot, envelopes: readonly ConversationPatchEnvelope[], ): ConversationReducerState { return envelopes.reduce( (state, envelope) => reduceConversationPatch(state, envelope), createConversationReducerState(snapshot), ); } function envelope( snapshot: ConversationSnapshot, seq: number, patch: ConversationPatch, ): ConversationPatchEnvelope { return { conversationId: snapshot.conversation.id, workerGeneration: snapshot.cursor.workerGeneration, seq, at: 2_000 + seq, patch, }; } describe('Conversation product contracts', () => { it('accepts max in the persisted model and available thinking-level contract', () => { const snapshot = createProductSnapshot(); snapshot.conversation.model = { model: { accountId: 'account-max', modelId: 'deepseek-v4-pro', thinkingLevel: 'max', }, modelResolution: 'resolved', availableThinkingLevels: ['off', 'low', 'high', 'max'], }; expect(isConversationSnapshot(snapshot)).toBe(true); }); it('accepts bounded capability envelopes for every Data Service operation', () => { const control = new Set(['configure', 'inspect', 'list_projects', 'remove_collection', 'reset', 'remove_project']); const operations = [ 'configure', 'inspect', 'list_projects', 'get_document', 'list_documents', 'put_document', 'delete_document', 'remove_collection', 'reset', 'remove_project', ]; for (const operation of operations) { const parsed = productToolDetails({ schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', capability_id: control.has(operation) ? 'data-service.control' : 'data-service.documents', operation, request_id: 'pi:run-a:resource-a', success: false, status: operation === 'put_document' ? 413 : operation === 'delete_document' ? 409 : 429, code: operation === 'put_document' ? 'document_too_large' : 'rate_limited', error: 'Data Service request was rejected', retryable: true, retry_after_seconds: 30, context: operation === 'put_document' ? { resource: 'bytes', actual: 100_000, limit: 98_304 } : operation === 'delete_document' ? { current_revision: 3 } : { resource: 'documents' }, billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', data: null, }); expect(parsed).toMatchObject({ schema: 'makelore-capability.v1', operation }); } expect(productToolDetails({ schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a', success: true, status: 200, code: null, error: null, retryable: false, billing: { mode: 'platform_metered', status: 'refunded', reserved_points: '2.50', actual_points: '1.25' }, payload_schema: 'data-service.v1', data: { id: 'one' }, })).toMatchObject({ billing: { mode: 'platform_metered', status: 'refunded', actual_points: '1.25' } }); expect(productToolDetails({ schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a', success: false, status: 409, code: 'quota_exceeded', error: 'Quota exceeded', retryable: false, billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', data: null, context: { allowed: 1 }, })).toBeNull(); expect(productToolDetails({ schema: 'makelore-capability.v1', plugin_id: 'makelore.data-service', plugin_version: '1.0.0', capability_id: 'data-service.documents', operation: 'get_document', request_id: 'pi:run-a:resource-a', success: true, status: 200, code: null, error: null, retryable: false, billing: { mode: 'included', status: 'included' }, payload_schema: 'data-service.v1', data: { body: 'raw upstream body must be projected' }, extra: 'reject', })).toBeNull(); }); it('keeps receipt-unavailable for remaining hosted plugins but rejects retired Web Search envelopes', () => { const envelope = { schema: 'makelore-capability.v1', plugin_id: 'makelore.game-resource', plugin_version: '1.0.0', capability_id: 'game-resource.generate', operation: 'generate', request_id: 'pi:run-a:resource-a', success: false, status: 503, code: 'plugin_receipt_unavailable', error: 'Billing status could not be synchronized; do not retry automatically', retryable: false, billing: { mode: 'platform_metered', status: 'receipt_unavailable' }, payload_schema: 'game-resource.v1', data: null, }; expect(productToolDetails(envelope)).toMatchObject({ plugin_id: 'makelore.game-resource', billing: { mode: 'platform_metered', status: 'receipt_unavailable' }, data: null, }); expect(productToolDetails({ ...envelope, plugin_id: 'makelore.web-search' })).toBeNull(); expect(productToolDetails({ ...envelope, billing: { mode: 'platform_metered', status: 'receipt_unavailable', reserved_points: '0.00', } })).toBeNull(); }); it('parses the closed model Web Search details without Plugin billing fields', () => { expect(productToolDetails({ schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', modelId: 'deepseek-v4-pro', answer: 'Grounded answer', sources: [{ title: 'Source', url: 'https://example.test/source' }], sourceMode: 'inline-or-structured', })).toEqual({ schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', modelId: 'deepseek-v4-pro', answer: 'Grounded answer', sources: [{ title: 'Source', url: 'https://example.test/source' }], sourceMode: 'inline-or-structured', }); expect(productToolDetails({ schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'failed', modelId: 'deepseek-v4-pro', error: { code: 'model_context_changed', message: 'Model changed', httpStatus: 409, retryable: false, }, })).toMatchObject({ status: 'failed', error: { code: 'model_context_changed' } }); expect(productToolDetails({ schema: 'makelore-model-tool.v1', tool: 'web_search', status: 'succeeded', modelId: 'deepseek-v4-pro', answer: 'Bad source', sources: [{ title: 'Source', url: 'file:///private.txt' }], sourceMode: 'inline-or-structured', })).toBeNull(); }); it('parses bounded device-package previews and installed indexes', () => { const preview = { schemaVersion: 1, planId: 'plan-1', expiresAt: '2026-09-02T00:05:00.000Z', requestedSource: '@scope/pi-tools@1.2.3', resolvedSource: '@scope/pi-tools@1.2.3', packageId: 'scope-pi-tools', displayName: 'Pi tools', resolvedVersion: '1.2.3', kind: 'mixed', skillEntries: [{ id: 'web-tools', entryPath: 'skills/web-tools/SKILL.md' }], extensionEntries: ['extensions/web-tools.ts'], includesExecutableCode: true, ignoredLifecycleScripts: ['postinstall'], warnings: ['This package contains executable Pi extensions.'], scope: 'device-parent-workers', }; expect(productToolDetails({ schema: 'makelore-device-package.v1', operation: 'prepare', success: true, preview, })).toMatchObject({ operation: 'prepare', preview: { packageId: 'scope-pi-tools' } }); const record = { schemaVersion: 1, packageId: 'scope-pi-tools', displayName: 'Pi tools', resolvedVersion: '1.2.3', source: { kind: 'npm', requested: '@scope/pi-tools', resolved: '@scope/pi-tools@1.2.3' }, kind: 'mixed', skillEntries: preview.skillEntries, extensionEntries: preview.extensionEntries, enabled: true, confirmedExecutableCode: true, installedAt: '2026-09-02T00:01:00.000Z', }; expect(productToolDetails({ schema: 'makelore-device-package.v1', operation: 'list', success: true, index: { schemaVersion: 1, generation: 2, packages: [record] }, })).toMatchObject({ operation: 'list', index: { generation: 2, packages: [{ enabled: true }] } }); expect(productToolDetails({ schema: 'makelore-device-package.v1', operation: 'list', success: true, index: { schemaVersion: 1, generation: 2, packages: [{ ...record, unexpected: true }] }, })).toBeNull(); }); it('accepts schema v1 snapshots and fail-closes unknown schemas until replacement', () => { const snapshot = createProductSnapshot(); expect(isConversationSnapshot(snapshot)).toBe(true); const invalid = createConversationReducerState({ ...snapshot, schemaVersion: 2 }); expect(invalid.snapshot).toBeNull(); expect(invalid.invalidation?.code).toBe('unsupported-schema'); const recovered = replaceConversationSnapshot(invalid, snapshot); expect(recovered.invalidation).toBeNull(); expect(recovered.snapshot).toEqual(snapshot); expect(recovered.snapshot).not.toBe(snapshot); }); it('reduces the locked-version mixed product fixture without vendor-shaped state', () => { expect(PI_PRODUCT_FIXTURE_SOURCE.version).toBe('0.84.2'); const state = reduceAll(createProductSnapshot(), MIXED_PRODUCT_PATCHES); expect(state.invalidation).toBeNull(); expect(state.snapshot?.cursor.seq).toBe(20); expect(state.snapshot?.run).toMatchObject({ status: 'idle', terminalReason: 'completed', }); expect(state.snapshot?.queue.items).toHaveLength(1); expect(state.snapshot?.pendingInteractions).toEqual([]); expect(state.snapshot?.context).toMatchObject({ usedTokens: 24_000, compaction: 'idle', lastCompactionId: 'compaction-a', }); const messages = state.snapshot?.nodes.filter((node) => node.kind === 'message') ?? []; const tools = state.snapshot?.nodes.filter((node) => node.kind === 'tool') ?? []; expect(messages).toHaveLength(2); expect(messages[0]).toMatchObject({ id: 'ui-user-a', sourceEntryId: 'entry-user-a', clientRequestId: 'request-a', status: 'complete', }); expect(messages[1]).toMatchObject({ id: 'ui-assistant-a', sourceEntryId: 'entry-assistant-a', status: 'complete', }); expect(tools).toHaveLength(2); expect(tools[0]).toMatchObject({ id: 'tool-ui-a', toolCallId: 'tool-call-a', status: 'complete', output: [{ text: 'first final' }], }); expect(tools[1]).toMatchObject({ status: 'error', output: [{ text: 'not found' }] }); expect(state.snapshot?.nodes.some((node) => ( node.kind === 'message' && (node as { role?: string }).role === 'toolResult' ))).toBe(false); expect(state.snapshot?.nodes).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'compaction', status: 'complete', willRetry: true }), expect.objectContaining({ kind: 'subagent' }), ])); }); it('replaces cumulative tool output instead of appending duplicate chunks', () => { const state = reduceAll(createProductSnapshot(), MIXED_PRODUCT_PATCHES.slice(0, 9)); const tool = state.snapshot?.nodes.find( (node) => node.kind === 'tool' && node.toolCallId === 'tool-call-a', ); expect(tool).toMatchObject({ kind: 'tool', output: [{ id: 'tool-output-a', text: 'first final', status: 'complete' }], }); if (tool?.kind !== 'tool') throw new Error('tool fixture missing'); expect(tool.output).toHaveLength(1); }); it('invalidates only the target Conversation on a sequence gap', () => { const snapshotA = createProductSnapshot('conversation-a'); const snapshotB = createProductSnapshot('conversation-b'); const stateA = reduceConversationPatch( createConversationReducerState(snapshotA), envelope(snapshotA, 2, { op: 'queue.replace', queue: { items: [] } }), ); const stateB = reduceConversationPatch( createConversationReducerState(snapshotB), envelope(snapshotA, 2, { op: 'queue.replace', queue: { items: [] } }), ); expect(stateA.invalidation).toMatchObject({ code: 'sequence-gap', expectedSeq: 1, actualSeq: 2, }); expect(stateB.invalidation).toBeNull(); expect(stateB.snapshot).toEqual(snapshotB); }); it('drops stale generation patches and invalidates a future generation', () => { const snapshot = createProductSnapshot('conversation-a', 4); const base = createConversationReducerState(snapshot); const stale = reduceConversationPatch(base, { ...envelope(snapshot, 1, { op: 'queue.replace', queue: { items: [] } }), workerGeneration: 3, }); expect(stale).toBe(base); const future = reduceConversationPatch(base, { ...envelope(snapshot, 1, { op: 'queue.replace', queue: { items: [] } }), workerGeneration: 5, }); expect(future.invalidation).toMatchObject({ code: 'generation-gap', expectedGeneration: 4, actualGeneration: 5, }); }); it('fail-closes unknown product operations and missing delta targets', () => { const snapshot = createProductSnapshot(); const unknown = reduceConversationPatch(createConversationReducerState(snapshot), { ...envelope(snapshot, 1, { op: 'queue.replace', queue: { items: [] } }), patch: { op: 'custom.vendor-event', raw: { secret: 'not projected' } }, }); expect(unknown.invalidation?.code).toBe('unknown-patch'); const missing = reduceConversationPatch(createConversationReducerState(snapshot), envelope( snapshot, 1, { op: 'message.block-delta', messageId: 'missing', blockId: 'missing', delta: 'x' }, )); expect(missing.invalidation?.code).toBe('patch-target-missing'); }); it('defines agent_end as a non-idle checkpoint and agent_settled as authoritative idle', () => { const agentEnd = PI_WIRE_TO_PRODUCT_BOUNDARY.find(({ wireEvent }) => wireEvent === 'agent_end'); const settled = PI_WIRE_TO_PRODUCT_BOUNDARY.find( ({ wireEvent }) => wireEvent === 'agent_settled', ); const custom = PI_WIRE_TO_PRODUCT_BOUNDARY.find( ({ wireEvent }) => wireEvent === 'custom.display-false', ); const retry = PI_WIRE_TO_PRODUCT_BOUNDARY.find(({ wireEvent }) => wireEvent === 'retry'); expect(agentEnd?.projectedPatches).toEqual([]); expect(custom?.projectedPatches).toEqual([]); expect(settled?.projectedPatches).toEqual([ expect.objectContaining({ op: 'run.state', run: expect.objectContaining({ status: 'idle' }) }), ]); expect(retry?.projectedPatches).toEqual([ expect.objectContaining({ op: 'run.state', run: expect.objectContaining({ status: 'retrying', retry: { attempt: 2, delayMs: 750 } }), }), ]); const running = createProductSnapshot(); running.run = { status: 'running', runId: 'run-a' }; const afterAgentEnd = reduceAll(running, []); expect(afterAgentEnd.snapshot?.run.status).toBe('running'); const afterRetry = reduceConversationPatch( afterAgentEnd, envelope(running, 1, retry?.projectedPatches[0] as ConversationPatch), ); expect(afterRetry.snapshot?.run).toMatchObject({ status: 'retrying', retry: { attempt: 2, delayMs: 750 }, }); const afterSettled = reduceConversationPatch( afterRetry, envelope(running, 2, settled?.projectedPatches[0] as ConversationPatch), ); expect(afterSettled.snapshot?.run.status).toBe('idle'); }); it('applies worker state through the same ordered envelope contract', () => { const snapshot = createProductSnapshot(); const next = reduceConversationPatch( createConversationReducerState(snapshot), envelope(snapshot, 1, { op: 'worker.state', state: { status: 'recovering', generation: 1 }, }), ); expect(next.invalidation).toBeNull(); expect(next.snapshot?.worker).toEqual({ status: 'recovering', generation: 1 }); }); it('rehydrates the same normalized Snapshot produced by live patches', () => { const live = reduceAll(createProductSnapshot(), MIXED_PRODUCT_PATCHES); if (!live.snapshot) throw new Error('live fixture did not produce a snapshot'); const hydrated = createConversationReducerState(live.snapshot); expect(hydrated.invalidation).toBeNull(); expect(hydrated.snapshot).toEqual(live.snapshot); expect(hydrated.snapshot).not.toBe(live.snapshot); }); }); describe('InMemoryConversationRuntime', () => { const input = { conversationId: 'conversation-memory', projectId: 'project-memory', agentId: 'agent-memory', title: 'Memory', model: { model: { accountId: 'account-memory', modelId: 'model-memory', thinkingLevel: 'low' as const, }, modelResolution: 'resolved' as const, }, }; it('supports idempotent prompt acceptance, queueing, subscription, and target abort', async () => { let tick = 10_000; let id = 0; const runtime = new InMemoryConversationRuntime({ now: () => ++tick, createId: (kind) => `${kind}-${++id}`, }); await runtime.prepare(input); const events: ConversationPatchEnvelope[] = []; const unsubscribe = runtime.subscribe((event) => events.push(event)); const promptInput = { clientRequestId: 'request-memory', conversationId: input.conversationId, mode: 'prompt' as const, text: 'Hello', attachments: [], }; const first = await runtime.prompt(promptInput); const duplicate = await runtime.prompt(promptInput); expect(duplicate).toEqual(first); expect(events).toHaveLength(2); const queued = await runtime.followUp({ clientRequestId: 'follow-up-memory', conversationId: input.conversationId, text: 'Next', attachments: [], }); expect(queued).toMatchObject({ mode: 'follow-up', queuePosition: 1 }); expect((await runtime.getSnapshot(input.conversationId)).queue.items).toHaveLength(1); await runtime.abort(input.conversationId); expect((await runtime.getSnapshot(input.conversationId)).run).toMatchObject({ status: 'idle', terminalReason: 'aborted', }); unsubscribe(); }); it('keeps active run state through willRetry compaction and isolates recovery generation', async () => { const runtime = new InMemoryConversationRuntime(); await runtime.prepare(input); await runtime.prompt({ clientRequestId: 'request-compaction', conversationId: input.conversationId, mode: 'prompt', text: 'Compact while running', attachments: [], }); await runtime.compact(input.conversationId); const compacted = await runtime.getSnapshot(input.conversationId); expect(compacted.run.status).toBe('running'); expect(compacted.context.compaction).toBe('idle'); expect(compacted.nodes).toEqual(expect.arrayContaining([ expect.objectContaining({ kind: 'compaction', status: 'complete', willRetry: true }), ])); const recovered = await runtime.recover(input.conversationId); expect(recovered).toMatchObject({ status: 'ready', workerGeneration: 1 }); const snapshot = await runtime.getSnapshot(input.conversationId); expect(snapshot.cursor).toMatchObject({ workerGeneration: 1, seq: 0 }); expect(snapshot.run.status).toBe('idle'); }); it('updates only the target model and forks a durable active-path prefix', async () => { const source = createProductSnapshot(input.conversationId); source.nodes = [ { kind: 'message', id: 'ui-user-1', sourceEntryId: 'entry-user-1', role: 'user', status: 'complete', blocks: [], }, { kind: 'message', id: 'ui-assistant-1', sourceEntryId: 'entry-assistant-1', role: 'assistant', status: 'complete', blocks: [], }, ]; const runtime = new InMemoryConversationRuntime({ snapshots: [source] }); await runtime.setModel({ conversationId: input.conversationId, accountId: 'account-b', modelId: 'model-b', }); const model = await runtime.setThinking({ conversationId: input.conversationId, thinkingLevel: 'high', }); expect(model.model).toMatchObject({ accountId: 'account-b', modelId: 'model-b', thinkingLevel: 'high', }); const fork = await runtime.fork({ sourceConversationId: input.conversationId, sourceEntryId: 'entry-user-1', conversation: { ...input, conversationId: 'conversation-fork', title: 'Fork' }, }); expect(fork.snapshot.nodes).toEqual([source.nodes[0]]); expect(fork.snapshot.worker.status).toBe('stopped'); expect((await runtime.getSnapshot(input.conversationId)).conversation.id) .toBe(input.conversationId); }); it('returns a stable public error when a Conversation is absent', async () => { const runtime = new InMemoryConversationRuntime(); await expect(runtime.getSnapshot('missing')).rejects.toMatchObject({ name: 'CodingRuntimeContractError', publicError: { code: 'CODING_CONVERSATION_NOT_FOUND', recoverable: true, }, } satisfies Partial); }); it('keeps the contracts, reducer, and in-memory implementation free of vendor imports', async () => { const files = [ '../../electron/coding-runtime/contracts.ts', '../../electron/coding-runtime/conversation-reducer.ts', '../../electron/coding-runtime/in-memory-conversation-runtime.ts', ]; for (const relativePath of files) { const source = await readFile(fileURLToPath(new URL(relativePath, import.meta.url)), 'utf8'); expect(source).not.toMatch(/(?:import|export)[\s\S]*?from\s+['"][^'"]*(?:opencode|pi(?:-|\/))/i); } }); });