feat: add conversation runtime contracts
This commit is contained in:
344
tests/fixtures/coding-conversation-product-fixtures.ts
vendored
Normal file
344
tests/fixtures/coding-conversation-product-fixtures.ts
vendored
Normal file
@@ -0,0 +1,344 @@
|
||||
import type {
|
||||
ConversationPatch,
|
||||
ConversationPatchEnvelope,
|
||||
ConversationSnapshot,
|
||||
} from '../../electron/coding-runtime/contracts';
|
||||
|
||||
export const PI_PRODUCT_FIXTURE_SOURCE = Object.freeze({
|
||||
package: '@earendil-works/pi-coding-agent',
|
||||
version: '0.84.2',
|
||||
rule: 'Wire labels are fixture provenance only; projected values use Makelore product types.',
|
||||
});
|
||||
|
||||
export function createProductSnapshot(
|
||||
conversationId = 'conversation-a',
|
||||
workerGeneration = 1,
|
||||
): ConversationSnapshot {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
conversation: {
|
||||
id: conversationId,
|
||||
projectId: 'project-a',
|
||||
agentId: 'agent-a',
|
||||
title: 'Fixture Conversation',
|
||||
model: {
|
||||
model: {
|
||||
accountId: 'account-a',
|
||||
modelId: 'model-a',
|
||||
thinkingLevel: 'medium',
|
||||
},
|
||||
modelResolution: 'resolved',
|
||||
},
|
||||
},
|
||||
nodes: [],
|
||||
run: { status: 'idle' },
|
||||
queue: { items: [] },
|
||||
context: {
|
||||
usedTokens: 0,
|
||||
contextWindow: 128_000,
|
||||
outputLimit: 8_192,
|
||||
compaction: 'idle',
|
||||
},
|
||||
pendingInteractions: [],
|
||||
worker: { status: 'ready', generation: workerGeneration },
|
||||
cursor: { workerGeneration, seq: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function envelope(seq: number, patch: ConversationPatch): ConversationPatchEnvelope {
|
||||
return {
|
||||
conversationId: 'conversation-a',
|
||||
workerGeneration: 1,
|
||||
runId: 'run-a',
|
||||
seq,
|
||||
at: 1_000 + seq,
|
||||
patch,
|
||||
};
|
||||
}
|
||||
|
||||
export const MIXED_PRODUCT_PATCHES: ConversationPatchEnvelope[] = [
|
||||
envelope(1, {
|
||||
op: 'run.state',
|
||||
run: { status: 'running', runId: 'run-a', mode: 'prompt', startedAt: 1_001 },
|
||||
}),
|
||||
envelope(2, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'ui-user-a',
|
||||
clientRequestId: 'request-a',
|
||||
role: 'user',
|
||||
status: 'optimistic',
|
||||
blocks: [{ kind: 'text', id: 'user-text-a', text: 'Build it', status: 'complete' }],
|
||||
},
|
||||
}),
|
||||
envelope(3, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'durable-user-a',
|
||||
sourceEntryId: 'entry-user-a',
|
||||
clientRequestId: 'request-a',
|
||||
role: 'user',
|
||||
status: 'complete',
|
||||
blocks: [{ kind: 'text', id: 'user-text-a', text: 'Build it', status: 'complete' }],
|
||||
},
|
||||
}),
|
||||
envelope(4, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'ui-assistant-a',
|
||||
sourceEntryId: 'entry-assistant-a',
|
||||
role: 'assistant',
|
||||
status: 'streaming',
|
||||
blocks: [
|
||||
{ kind: 'thinking', id: 'thinking-a', text: 'Plan', status: 'streaming' },
|
||||
{ kind: 'text', id: 'assistant-text-a', text: 'Working', status: 'streaming' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
envelope(5, {
|
||||
op: 'message.block-delta',
|
||||
messageId: 'ui-assistant-a',
|
||||
blockId: 'assistant-text-a',
|
||||
delta: ' now',
|
||||
}),
|
||||
envelope(6, {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-ui-a',
|
||||
toolCallId: 'tool-call-a',
|
||||
toolName: 'write',
|
||||
title: 'Write file',
|
||||
inputText: '{"path":"src/a.ts"}',
|
||||
status: 'running',
|
||||
output: [],
|
||||
details: { schema: 'write-lease.v1', status: 'held' },
|
||||
},
|
||||
}),
|
||||
envelope(7, {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-ui-b',
|
||||
toolCallId: 'tool-call-b',
|
||||
toolName: 'read',
|
||||
title: 'Read file',
|
||||
inputText: '{"path":"src/b.ts"}',
|
||||
status: 'running',
|
||||
output: [],
|
||||
},
|
||||
}),
|
||||
envelope(8, {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-wire-a',
|
||||
toolCallId: 'tool-call-a',
|
||||
toolName: 'write',
|
||||
title: 'Write file',
|
||||
inputText: '{"path":"src/a.ts"}',
|
||||
status: 'running',
|
||||
output: [{ kind: 'text', id: 'tool-output-a', text: 'first', status: 'streaming' }],
|
||||
details: { schema: 'changed-file.v1', paths: ['src/a.ts'] },
|
||||
},
|
||||
}),
|
||||
envelope(9, {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-wire-a',
|
||||
toolCallId: 'tool-call-a',
|
||||
toolName: 'write',
|
||||
title: 'Write file',
|
||||
inputText: '{"path":"src/a.ts"}',
|
||||
status: 'complete',
|
||||
output: [{ kind: 'text', id: 'tool-output-a', text: 'first final', status: 'complete' }],
|
||||
details: { schema: 'changed-file.v1', paths: ['src/a.ts'] },
|
||||
},
|
||||
}),
|
||||
envelope(10, {
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-wire-b',
|
||||
toolCallId: 'tool-call-b',
|
||||
toolName: 'read',
|
||||
title: 'Read file',
|
||||
inputText: '{"path":"src/b.ts"}',
|
||||
status: 'error',
|
||||
output: [{ kind: 'text', id: 'tool-output-b', text: 'not found', status: 'complete' }],
|
||||
},
|
||||
}),
|
||||
envelope(11, {
|
||||
op: 'queue.replace',
|
||||
queue: {
|
||||
items: [{
|
||||
id: 'queue-a',
|
||||
clientRequestId: 'request-follow-up',
|
||||
mode: 'follow-up',
|
||||
text: 'Add tests',
|
||||
attachmentIds: [],
|
||||
}],
|
||||
},
|
||||
}),
|
||||
envelope(12, {
|
||||
op: 'interaction.upsert',
|
||||
interaction: {
|
||||
id: 'interaction-a',
|
||||
conversationId: 'conversation-a',
|
||||
runId: 'run-a',
|
||||
kind: 'select',
|
||||
title: 'Choose target',
|
||||
options: [{ id: 'web', label: 'Web' }, { id: 'desktop', label: 'Desktop' }],
|
||||
status: 'pending',
|
||||
},
|
||||
}),
|
||||
envelope(13, {
|
||||
op: 'compaction.upsert',
|
||||
node: {
|
||||
kind: 'compaction',
|
||||
id: 'compaction-a',
|
||||
runId: 'run-a',
|
||||
source: 'automatic',
|
||||
status: 'running',
|
||||
willRetry: true,
|
||||
},
|
||||
}),
|
||||
envelope(14, {
|
||||
op: 'context.replace',
|
||||
context: {
|
||||
usedTokens: 96_000,
|
||||
contextWindow: 128_000,
|
||||
outputLimit: 8_192,
|
||||
compaction: 'running',
|
||||
lastCompactionId: 'compaction-a',
|
||||
},
|
||||
}),
|
||||
envelope(15, {
|
||||
op: 'subagent.upsert',
|
||||
node: {
|
||||
kind: 'subagent',
|
||||
id: 'subagent-a',
|
||||
runId: 'run-a',
|
||||
details: {
|
||||
schema: 'subagent.v1',
|
||||
dispatchId: 'dispatch-a',
|
||||
mode: 'parallel',
|
||||
tasks: [
|
||||
{
|
||||
taskId: 'child-a',
|
||||
agentId: 'reviewer',
|
||||
toolProfile: 'read-only',
|
||||
status: 'complete',
|
||||
summary: 'Reviewed',
|
||||
},
|
||||
{
|
||||
taskId: 'child-b',
|
||||
agentId: 'tester',
|
||||
toolProfile: 'coding',
|
||||
status: 'running',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
envelope(16, {
|
||||
op: 'compaction.upsert',
|
||||
node: {
|
||||
kind: 'compaction',
|
||||
id: 'compaction-a',
|
||||
runId: 'run-a',
|
||||
source: 'automatic',
|
||||
status: 'complete',
|
||||
willRetry: true,
|
||||
},
|
||||
}),
|
||||
envelope(17, {
|
||||
op: 'context.replace',
|
||||
context: {
|
||||
usedTokens: 24_000,
|
||||
contextWindow: 128_000,
|
||||
outputLimit: 8_192,
|
||||
compaction: 'idle',
|
||||
lastCompactionId: 'compaction-a',
|
||||
},
|
||||
}),
|
||||
envelope(18, { op: 'interaction.remove', interactionId: 'interaction-a' }),
|
||||
envelope(19, {
|
||||
op: 'message.upsert',
|
||||
node: {
|
||||
kind: 'message',
|
||||
id: 'durable-assistant-a',
|
||||
sourceEntryId: 'entry-assistant-a',
|
||||
role: 'assistant',
|
||||
status: 'complete',
|
||||
blocks: [
|
||||
{ kind: 'thinking', id: 'thinking-a', text: 'Plan complete', status: 'complete' },
|
||||
{ kind: 'text', id: 'assistant-text-a', text: 'Done', status: 'complete' },
|
||||
],
|
||||
usage: { inputTokens: 120, outputTokens: 40 },
|
||||
stopReason: 'stop',
|
||||
},
|
||||
}),
|
||||
envelope(20, {
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-a',
|
||||
settledAt: 1_020,
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
export const PI_WIRE_TO_PRODUCT_BOUNDARY = Object.freeze([
|
||||
{
|
||||
wireEvent: 'agent_end',
|
||||
projectedPatches: [] as ConversationPatch[],
|
||||
},
|
||||
{
|
||||
wireEvent: 'agent_settled',
|
||||
projectedPatches: [{
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'idle',
|
||||
runId: 'run-a',
|
||||
terminalReason: 'completed',
|
||||
},
|
||||
}] satisfies ConversationPatch[],
|
||||
},
|
||||
{
|
||||
wireEvent: 'retry',
|
||||
projectedPatches: [{
|
||||
op: 'run.state',
|
||||
run: {
|
||||
status: 'retrying',
|
||||
runId: 'run-a',
|
||||
retry: { attempt: 2, delayMs: 750 },
|
||||
},
|
||||
}] satisfies ConversationPatch[],
|
||||
},
|
||||
{
|
||||
wireEvent: 'toolResult.message_end',
|
||||
projectedPatches: [{
|
||||
op: 'tool.upsert',
|
||||
node: {
|
||||
kind: 'tool',
|
||||
id: 'tool-ui-a',
|
||||
toolCallId: 'tool-call-a',
|
||||
toolName: 'write',
|
||||
title: 'Write file',
|
||||
inputText: '{}',
|
||||
status: 'complete',
|
||||
output: [{ kind: 'text', id: 'tool-output-a', text: 'done', status: 'complete' }],
|
||||
},
|
||||
}] satisfies ConversationPatch[],
|
||||
},
|
||||
{
|
||||
wireEvent: 'custom.display-false',
|
||||
projectedPatches: [] as ConversationPatch[],
|
||||
},
|
||||
]);
|
||||
397
tests/unit/coding-conversation-contracts.test.ts
Normal file
397
tests/unit/coding-conversation-contracts.test.ts
Normal file
@@ -0,0 +1,397 @@
|
||||
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';
|
||||
|
||||
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 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<CodingRuntimeContractError>);
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user