Files
makelore/tests/unit/coding-conversation-contracts.test.ts

579 lines
22 KiB
TypeScript

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<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);
}
});
});