blob: b64445479ad29002a551b7ce3146f710754ef74d [file]
/**
* Tests for materializer.
*
* Run: `bun test packages/runtime/src/__tests__/materializer.test.ts`
*/
import { describe, test } from 'node:test';
import { expect } from '../test-helpers.js';
import type {
UserMessage,
AssistantMessage,
ToolCallMessage,
ToolResultMessage,
PermissionDecisionMessage,
TokenUsageMessage,
SystemNoteMessage,
} from '@maka/core';
import {
materializeSession,
applyAppendedMessage,
setToolStatus,
type ChatItem,
} from '../materializer.js';
// ---------- Fixtures ----------
const ts = 1_700_000_000_000;
const turnId = 't1';
const user = (id: string, text: string): UserMessage => ({
type: 'user',
id,
turnId,
ts: ts + 1,
text,
});
const assistant = (id: string, text: string): AssistantMessage => ({
type: 'assistant',
id,
turnId,
ts: ts + 2,
text,
modelId: 'claude-sonnet-4-5',
});
const toolCall = (id: string, name: string, args: unknown = {}): ToolCallMessage => ({
type: 'tool_call',
id,
turnId,
ts: ts + 3,
toolName: name,
args,
});
const toolResult = (toolUseId: string, isError: boolean, text: string): ToolResultMessage => ({
type: 'tool_result',
id: `r-${toolUseId}`,
turnId,
ts: ts + 4,
toolUseId,
isError,
content: { kind: 'text', text },
});
const permission = (
requestId: string,
toolUseId: string,
decision: 'allow' | 'deny',
): PermissionDecisionMessage => ({
type: 'permission_decision',
id: requestId,
turnId,
ts: ts + 3,
toolUseId,
toolName: 'Write',
decision,
});
const tokens = (input: number, output: number, costUsd?: number): TokenUsageMessage => ({
type: 'token_usage',
id: 'tu',
turnId,
ts: ts + 5,
input,
output,
...(costUsd !== undefined ? { costUsd } : {}),
});
const note = (kind: SystemNoteMessage['kind']): SystemNoteMessage => ({
type: 'system_note',
id: 'n',
ts: ts + 6,
kind,
});
// ---------- materializeSession ----------
describe('materializeSession', () => {
test('empty', () => {
const vm = materializeSession([]);
expect(vm.items).toEqual([]);
expect(vm.totalTokens.input).toBe(0);
expect(vm.totalTokens.output).toBe(0);
});
test('user + assistant', () => {
const vm = materializeSession([user('u', 'hello'), assistant('a', 'hi')]);
expect(vm.items).toHaveLength(2);
expect(vm.items[0]?.kind).toBe('user');
expect(vm.items[1]?.kind).toBe('assistant');
});
test('completed tool: call + result paired into single ChatItem', () => {
const vm = materializeSession([
toolCall('t-1', 'Read', { path: '/x' }),
toolResult('t-1', false, 'contents'),
]);
expect(vm.items).toHaveLength(1);
const item = vm.items[0];
expect(item?.kind).toBe('tool');
if (item?.kind !== 'tool') return;
expect(item.item.toolUseId).toBe('t-1');
expect(item.item.status).toBe('completed');
expect(item.item.isError).toBe(false);
expect(item.item.result).toEqual({ kind: 'text', text: 'contents' });
});
test('errored tool: result with isError=true → status errored', () => {
const vm = materializeSession([
toolCall('t-2', 'Write'),
toolResult('t-2', true, 'Permission denied'),
]);
expect(vm.items).toHaveLength(1);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('errored');
expect(item.item.isError).toBe(true);
});
test('cancelled terminal with isError=true → status interrupted', () => {
const cancelled: ToolResultMessage = {
type: 'tool_result',
id: 'r-cancel',
turnId,
ts: ts + 4,
toolUseId: 't-cancel',
isError: true,
content: {
kind: 'terminal',
cwd: '/repo',
cmd: 'sleep 99',
status: 'cancelled',
exitCode: 130,
output: {
mode: 'pipes',
stdout: '',
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
redacted: false,
},
},
};
const vm = materializeSession([toolCall('t-cancel', 'Bash'), cancelled]);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('interrupted');
const live = applyAppendedMessage(
applyAppendedMessage([], toolCall('t-cancel', 'Bash')).items,
cancelled,
);
const liveItem = live.items[0];
if (liveItem?.kind !== 'tool') throw new Error('wrong kind');
expect(liveItem.item.status).toBe('interrupted');
});
test('successful shell_run cancelled observation stays completed', () => {
// StopBackgroundTask returns isError:false + shell_run.status cancelled —
// the stop call succeeded; do not map to interrupted/error.
const observed: ToolResultMessage = {
type: 'tool_result',
id: 'r-stop',
turnId,
ts: ts + 4,
toolUseId: 't-stop',
isError: false,
content: {
kind: 'shell_run',
ref: 'maka://runtime/background-tasks/bg',
mode: 'pipes',
status: 'cancelled',
cwd: '/repo',
cmd: 'sleep 99',
startedAt: 1,
updatedAt: 2,
exitCode: 130,
revision: 2,
output: {
mode: 'pipes',
stdout: '',
stderr: '',
stdoutTruncated: false,
stderrTruncated: false,
redacted: false,
},
operation: { kind: 'stop', applied: true },
},
};
const vm = materializeSession([toolCall('t-stop', 'StopBackgroundTask'), observed]);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('completed');
});
test('orphan tool_call (no matching result, no turn record) → interrupted', () => {
const vm = materializeSession([toolCall('t-orphan', 'Bash')]);
expect(vm.items).toHaveLength(1);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('interrupted');
expect(item.item.result).toBeUndefined();
});
// A missing result is the absence of evidence, not evidence of a terminal
// state. Only a turn that has itself ended makes it mean "never finished" —
// while the turn runs, so does the call.
for (const [turnStatus, expected] of [
['running', 'running'],
['aborted', 'interrupted'],
['failed', 'interrupted'],
['completed', 'interrupted'],
] as const) {
test(`resultless tool_call in a ${turnStatus} turn → ${expected}`, () => {
const vm = materializeSession([
{
type: 'turn_state',
id: `state-${turnStatus}`,
turnId,
ts,
status: turnStatus,
partialOutputRetained: false,
},
toolCall('t-inflight', 'Bash'),
]);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe(expected);
});
}
test('permission decision folded into tool ChatItem', () => {
const vm = materializeSession([
toolCall('t-3', 'Write'),
permission('req-1', 't-3', 'allow'),
toolResult('t-3', false, 'ok'),
]);
expect(vm.items).toHaveLength(1);
const item = vm.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.decision?.decision).toBe('allow');
expect(item.decision?.id).toBe('req-1');
});
test('token usage accumulated, not rendered as ChatItem', () => {
const vm = materializeSession([tokens(100, 50, 0.001), tokens(200, 80, 0.002)]);
expect(vm.items).toHaveLength(0);
expect(vm.totalTokens.input).toBe(300);
expect(vm.totalTokens.output).toBe(130);
expect(vm.totalTokens.costUsd).toBeCloseTo(0.003);
});
test('system_note rendered as ChatItem', () => {
const vm = materializeSession([note('session_start'), note('abort')]);
expect(vm.items).toHaveLength(2);
expect(vm.items[0]?.kind).toBe('system_note');
});
test('mixed full conversation', () => {
const vm = materializeSession([
note('session_start'),
user('u1', 'do X'),
toolCall('t-a', 'Read'),
toolResult('t-a', false, 'data'),
assistant('a1', 'Done.'),
tokens(50, 20),
]);
expect(vm.items.map((i) => i.kind)).toEqual(['system_note', 'user', 'tool', 'assistant']);
expect(vm.totalTokens.input).toBe(50);
expect(vm.totalTokens.output).toBe(20);
});
});
// ---------- applyAppendedMessage ----------
describe('applyAppendedMessage', () => {
test('preserves a semantic activity kind during reload and live append', () => {
const call = { ...toolCall('t', 'custom_shell'), activityKind: 'command' as const };
const reloaded = materializeSession([call]);
const appended = applyAppendedMessage([], call);
const reloadedItem = reloaded.items[0];
const appendedItem = appended.items[0];
if (reloadedItem?.kind !== 'tool' || appendedItem?.kind !== 'tool')
throw new Error('wrong kind');
expect(reloadedItem.item.activityKind).toBe('command');
expect(appendedItem.item.activityKind).toBe('command');
});
test('append user → adds bubble', () => {
const next = applyAppendedMessage([], user('u', 'hi'));
expect(next.items).toHaveLength(1);
expect(next.items[0]?.kind).toBe('user');
});
test('append tool_call → pending tool item', () => {
const next = applyAppendedMessage([], toolCall('t', 'Read'));
expect(next.items).toHaveLength(1);
const item = next.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('pending');
});
test('append tool_result → patches matching tool item by toolUseId', () => {
const items: ChatItem[] = applyAppendedMessage([], toolCall('t', 'Write')).items;
const next = applyAppendedMessage(items, toolResult('t', false, 'wrote'));
expect(next.items).toHaveLength(1);
expect(next.modifiedToolUseId).toBe('t');
const item = next.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('completed');
expect(item.item.result).toEqual({ kind: 'text', text: 'wrote' });
});
test('append tool_result with isError=true → status errored', () => {
const items = applyAppendedMessage([], toolCall('t', 'Write')).items;
const next = applyAppendedMessage(items, toolResult('t', true, 'denied'));
const item = next.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('errored');
expect(item.item.isError).toBe(true);
});
test('append tool_result for unknown toolUseId → no-op (list unchanged)', () => {
const items = applyAppendedMessage([], user('u', 'hello')).items;
const next = applyAppendedMessage(items, toolResult('nonexistent', false, 'x'));
expect(next.items).toEqual(items);
});
test('append permission_decision → patches tool item.decision', () => {
const items = applyAppendedMessage([], toolCall('t', 'Write')).items;
const next = applyAppendedMessage(items, permission('req', 't', 'deny'));
const item = next.items[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.decision?.decision).toBe('deny');
});
test('append token_usage → does not add item', () => {
const next = applyAppendedMessage([], tokens(10, 5));
expect(next.items).toEqual([]);
});
});
// ---------- setToolStatus (renderer idempotent merge per §10) ----------
describe('setToolStatus', () => {
test('updates by toolUseId without duplicating', () => {
const items = applyAppendedMessage([], toolCall('t', 'Read')).items;
// Two distinct hops off `pending`, so this stays a transition test rather
// than a second copy of the idempotence test below.
const stage1 = setToolStatus(items, 't', { status: 'running' });
const stage2 = setToolStatus(stage1, 't', { status: 'completed' });
expect(stage2).toHaveLength(1);
const item = stage2[0];
if (item?.kind !== 'tool') throw new Error('wrong kind');
expect(item.item.status).toBe('completed');
});
test('idempotent on duplicate updates', () => {
const items = applyAppendedMessage([], toolCall('t', 'Read')).items;
const once = setToolStatus(items, 't', { status: 'running' });
const twice = setToolStatus(once, 't', { status: 'running' });
expect(twice).toEqual(once);
});
test('unknown toolUseId → no-op', () => {
const items = applyAppendedMessage([], user('u', 'hi')).items;
const next = setToolStatus(items, 'nonexistent', { status: 'running' });
expect(next).toEqual(items);
});
});