SPB Git

Repository Public

lib.test.ts 14.1 KB 174 lines typescript

1import { describe, expect, it } from 'vitest';2import type { WaitDto } from '@aos/shared';3import { additionsOnly, lineDiff, looksLikeUnifiedDiff, parseUnifiedDiff } from '@/lib/diff';4import { parseInline, parseMarkdown } from '@/lib/markdown';5import { buildItems, mergeSteps, refusedCalls, setupLine, shellStatus, syncCursor, toolView, type PolicyDecision, type Step } from '@/lib/timeline';6import { elapsedSec, fmtDuration, fmtTokens, fmtUsd } from '@/lib/format';78const RUN = '11111111-1111-4111-8111-111111111111';9const step = (seq: number, p: Partial<Step> = {}): Step => ({ runId: RUN, seq, kind: 'tool_call', name: 'read_file', effect: 'read', status: 'completed', request: { name: 'read_file', input: { path: 'a.ts' } }, result: { ok: true, content: 'x' }, error: null, usage: null, startedAt: '2026-11-10T10:00:00.000Z', completedAt: '2026-11-10T10:00:01.000Z', ...p });10const model = (seq: number, p: Partial<Step> = {}): Step => step(seq, { kind: 'model_call', name: 'claude-opus-5-5', effect: 'model_call', request: {}, result: { blocks: [{ type: 'reasoning', summary: 'why' }, { type: 'text', text: 'hello' }, { type: 'tool_call', id: 'c1', name: 'read_file', input: { path: 'a.ts' } }], stop: 'tool_use', model: 'claude-opus-5-5' }, ...p });1112describe('diff', () => {13  const GIT = `diff --git a/src/a.ts b/src/a.ts\nindex 1..2 100644\n--- a/src/a.ts\n+++ b/src/a.ts\n@@ -1,4 +1,5 @@\n keep\n-old line\n+new line\n+another\n keep 2\n keep 3\ndiff --git a/b.txt b/b.txt\nnew file mode 100644\n--- /dev/null\n+++ b/b.txt\n@@ -0,0 +1,2 @@\n+one\n+two\n`;14  it('parses a multi-file git diff with line numbers', () => {15    expect(looksLikeUnifiedDiff(GIT)).toBe(true);16    const files = parseUnifiedDiff(GIT);17    expect(files.map((f) => [f.path, f.added, f.removed, f.note])).toEqual([['src/a.ts', 2, 1, null], ['b.txt', 2, 0, 'new file']]);18    const lines = files[0]!.hunks[0]!.lines;19    expect(lines.map((l) => l.type)).toEqual(['ctx', 'del', 'add', 'add', 'ctx', 'ctx']);20    expect(lines[1]).toMatchObject({ oldNo: 2, newNo: null, text: 'old line' });21    expect(lines[3]).toMatchObject({ oldNo: null, newNo: 3, text: 'another' });22    expect(lines[5]).toMatchObject({ oldNo: 4, newNo: 5 });23  });24  it('does not mistake prose for a diff', () => {25    expect(looksLikeUnifiedDiff('--- not a diff\n+++ really')).toBe(false);26    expect(parseUnifiedDiff('hello\nworld')).toEqual([]);27  });28  it('line-diffs two strings (LCS, common prefix/suffix kept as context)', () => {29    const h = lineDiff('a\nb\nc\nd', 'a\nB\nc\nd\ne');30    expect(h.lines.map((l) => `${l.type}:${l.text}`)).toEqual(['ctx:a', 'del:b', 'add:B', 'ctx:c', 'ctx:d', 'add:e']);31    expect(h.lines.find((l) => l.text === 'e')).toMatchObject({ newNo: 5 });32    expect(lineDiff('', 'x').lines).toEqual([{ type: 'add', text: 'x', oldNo: null, newNo: 1 }]);33    expect(lineDiff('same', 'same').lines.every((l) => l.type === 'ctx')).toBe(true);34  });35  it('falls back to remove-all/add-all for huge inputs instead of hanging', () => {36    const a = Array.from({ length: 900 }, (_, i) => `a${i}`).join('\n');37    const b = Array.from({ length: 900 }, (_, i) => `b${i}`).join('\n');38    const h = lineDiff(a, b);39    expect(h.lines.filter((l) => l.type === 'del')).toHaveLength(900);40    expect(h.lines.filter((l) => l.type === 'add')).toHaveLength(900);41  });42  it('renders new content as additions', () => {43    expect(additionsOnly('x\ny\n').lines.map((l) => [l.type, l.newNo])).toEqual([['add', 1], ['add', 2]]);44  });45});4647describe('markdown (safe subset)', () => {48  it('parses blocks', () => {49    const b = parseMarkdown('# Title\n\ntext **bold** and `code`\n\n- one\n- two\n\n1. first\n2. second\n\n```ts\nconst a = "<b>";\n```\n> quote');50    expect(b.map((x) => x.t)).toEqual(['h', 'p', 'ul', 'ol', 'code', 'quote']);51    expect(b[4]).toEqual({ t: 'code', lang: 'ts', text: 'const a = "<b>";' });52  });53  it('never produces links or images: they stay literal text', () => {54    const src = 'see [docs](https://evil.example/x) ![img](https://evil.example/p.png) https://auto.example <script>alert(1)</script>';55    const inl = parseInline(src);56    expect(inl.every((p) => p.t === 'text')).toBe(true);57    expect(inl.map((p) => p.v).join('')).toBe(src);58  });59  it('keeps an unterminated fence open (streaming text)', () => {60    expect(parseMarkdown('before\n```js\nlet x = 1')).toEqual([{ t: 'p', inl: [{ t: 'text', v: 'before' }] }, { t: 'code', lang: 'js', text: 'let x = 1' }]);61  });62  it('does not treat stray asterisks as emphasis', () => {63    expect(parseInline('2 * 3 * 4').map((p) => p.t)).toEqual(['text']);64    expect(parseInline('a **b** *c*').map((p) => p.t)).toEqual(['text', 'bold', 'text', 'italic']);65  });66});6768describe('timeline', () => {69  it('merges by seq without duplicates, incoming wins, sorted', () => {70    const a = [step(1), step(2, { status: 'started', result: null, completedAt: null })];71    const merged = mergeSteps(a, [step(2), step(3), step(2)]);72    expect(merged.map((s) => s.seq)).toEqual([1, 2, 3]);73    expect(merged[1]!.status).toBe('completed');74    expect(mergeSteps(merged, merged).map((s) => s.seq)).toEqual([1, 2, 3]); // replaying everything changes nothing75  });76  it('syncs from the first unsettled step', () => {77    expect(syncCursor([])).toBeNull();78    expect(syncCursor([step(1), step(2)])).toBe(2);79    expect(syncCursor([step(1), step(2, { status: 'started' }), step(3)])).toBe(1);80  });81  it('groups by turn and replaces the stream with the committed step', () => {82    const base = { waits: [], decisions: [], pendingMessages: [], showSystem: false };83    const streaming = buildItems({ ...base, steps: [model(1, { status: 'started', result: null, completedAt: null })], stream: { text: 'hel', reasoning: '' } });84    expect(streaming.map((i) => i.type)).toEqual(['turn', 'text']);85    expect(streaming[1]).toMatchObject({ streaming: true, text: 'hel' });86    const committed = buildItems({ ...base, steps: [model(1), step(2), model(3), step(4, { kind: 'now', name: 'now', effect: 'pure' })], stream: null });87    expect(committed.map((i) => i.type)).toEqual(['turn', 'reasoning', 'text', 'tool', 'turn', 'reasoning', 'text']);88    expect(new Set(committed.map((i) => i.key)).size).toBe(committed.length);89    expect(buildItems({ ...base, steps: [model(1, { status: 'started', result: null })], stream: null }).map((i) => i.type)).toEqual(['turn', 'thinking']);90  });91  it('places waits, owner messages and decisions (real journal shapes)', () => {92    const payload = { tool: 'git_push', input: { remote: null, branch: null }, risk: 'external', title: 'Push', detail: 'd', reason: 'default: external effect needs approval' };93    const wait = { id: 'w1', runId: RUN, missionId: RUN, missionTitle: 't', stepSeq: 4, kind: 'approval', payload, status: 'pending', resolution: null, resolveBy: null, createdAt: '', resolvedAt: null } as WaitDto;94    const push = { blocks: [{ type: 'text', text: 'pushing' }, { type: 'tool_call', id: 'p1', name: 'git_push', input: { branch: null, remote: null } }], stop: 'tool_use', model: 'm' };95    const steps = [96      step(0, { kind: 'setup', name: 'run_meta', effect: 'read', request: {}, result: {} }),97      step(1, { kind: 'setup', name: 'git_branch', effect: 'idempotent_write', request: { branch: 'aos/1234' }, result: { repo: true, branch: 'aos/1234', base: 'main', error: null } }),98      step(2, { kind: 'owner_messages', name: 'owner_messages', request: { afterId: 0 }, result: { messages: [{ id: 9, text: 'use cents' }] } }),99      model(3, { request: { turn: 7, model: 'm' }, result: push }),100      step(4, { kind: 'wait', name: 'approval', effect: 'pure', status: 'started', request: { kind: 'approval', payload, timeoutSec: 259200 }, result: null }),101    ];102    const ask: PolicyDecision = { id: 5, seq: null, ts: '', decision: 'ask', tool: 'git_push', reason: 'default: external effect needs approval', callId: 'p1', source: 'default' };103    const allow: PolicyDecision = { ...ask, id: 4, decision: 'allow', callId: 'zz' };104    const items = buildItems({ steps, waits: [wait], decisions: [ask, allow], pendingMessages: [{ key: 'ev-9', text: 'use cents', ts: null, pending: true }, { key: 'local-1', text: 'second', ts: null, pending: true }], stream: null, showSystem: false });105    // housekeeping hidden except the branch line; the turn number is the journaled one; the decision sits with its turn; nothing is "refused" (the approval wait stands for the call)106    expect(items.map((i) => i.type)).toEqual(['system', 'owner', 'turn', 'text', 'decision', 'wait', 'owner']);107    expect(items[2]).toMatchObject({ label: 'Turn 7' });108    expect(setupLine(steps[1]!)).toBe('Working on branch aos/1234 (from main)');109    const w = items.find((i) => i.type === 'wait');110    expect(w && w.type === 'wait' && w.wait?.id).toBe('w1');111    expect(items.filter((i) => i.type === 'owner')).toHaveLength(2); // the journaled one is not repeated as pending112    expect(buildItems({ steps, waits: [], decisions: [], pendingMessages: [], stream: null, showSystem: true }).filter((i) => i.type === 'system')).toHaveLength(2);113  });114  it('shows tool calls the model issued that were never executed', () => {115    const blocks = [116      { type: 'tool_call', id: 'a', name: 'read_file', input: { path: 'a.ts', offset: null } },117      { type: 'tool_call', id: 'b', name: 'shell', input: { command: 'rm -rf x', timeoutSec: null } }, // denied by policy: no step118      { type: 'tool_call', id: 'c', name: 'edit_file', input: { path: 5 } }, // invalid input: no step119      { type: 'tool_call', id: 'd', name: 'ask_owner', input: { question: 'q?', options: null } },120    ];121    const m = model(1, { result: { blocks, stop: 'tool_use', model: 'm' } });122    const following = [123      step(2, { kind: 'policy', name: 'policy_rules', effect: 'pure', request: {}, result: { rules: [] } }),124      step(3, { request: { name: 'read_file', input: { offset: null, path: 'a.ts' } } }), // jsonb key order differs125      step(4, { kind: 'wait', name: 'question', effect: 'pure', request: { kind: 'question', payload: { text: 'q?', source: 'ask_owner' }, timeoutSec: null }, result: { answer: 'yes' } }),126    ];127    expect(refusedCalls(m, following, false)).toEqual([]); // the batch may still be running128    expect(refusedCalls(m, following, true).map((c) => c.id)).toEqual(['b', 'c']);129    const deny: PolicyDecision = { id: 9, seq: null, ts: '', decision: 'deny', tool: 'shell', reason: 'global rule: deny shell (no deletions)', callId: 'b', source: 'rule' };130    const items = buildItems({ steps: [m, ...following, model(5, { result: { blocks: [], stop: 'end_turn', model: 'm' } })], waits: [], decisions: [deny], pendingMessages: [], stream: null, showSystem: false });131    expect(items.map((i) => i.type)).toEqual(['turn', 'refused', 'refused', 'tool', 'wait', 'turn']);132    const refused = items.filter((i) => i.type === 'refused').map((i) => (i.type === 'refused' ? i.call : null));133    expect(refused[0]).toMatchObject({ tool: 'shell', keyArg: 'rm -rf x', reason: 'global rule: deny shell (no deletions)' });134    expect(refused[1]).toMatchObject({ tool: 'edit_file', reason: null }); // after a reload the reason is unknown: never invented135    // a run that ended right after the turn: the batch is over too136    expect(buildItems({ steps: [m], waits: [], decisions: [], pendingMessages: [], stream: null, showSystem: false, runEnded: true }).filter((i) => i.type === 'refused')).toHaveLength(4);137  });138  it('reads tool steps as the tools record them', () => {139    const diff = '--- a/a.ts\n+++ b/a.ts\n@@ -1,2 +1,2 @@\n a\n-b\n+c\n';140    const edit = toolView(step(5, { name: 'edit_file', request: { name: 'edit_file', input: { path: 'a.ts', oldString: 'b', newString: 'c', replaceAll: null } }, result: { ok: true, content: 'Edited a.ts: 1 replacement.', diff, artifact: null, trust: 'tool' } }));141    expect(edit.diff?.[0]).toMatchObject({ path: 'a.ts', added: 1, removed: 1 });142    expect(edit.diff?.[0]?.hunks[0]?.lines[0]).toMatchObject({ type: 'ctx', text: 'a' }); // from result.diff (real context), not rebuilt from the input143    const refusedEdit = toolView(step(5, { name: 'edit_file', request: { name: 'edit_file', input: { path: 'a.ts', oldString: 'b', newString: 'c' } }, result: { ok: false, content: 'You have not read a.ts in this run.' } }));144    expect([refusedEdit.ok, refusedEdit.diff]).toEqual([false, null]); // a failed edit changed nothing: no diff is shown145    const sh = toolView(step(6, { name: 'shell', request: { name: 'shell', input: { command: 'npm test', timeoutSec: null } }, result: { ok: false, content: '[exit code 2]\nboom\nexit 0', artifact: null } }));146    expect(sh).toMatchObject({ tool: 'shell', keyArg: 'npm test', ok: false, exitCode: 2, output: 'boom\nexit 0', statusNote: null });147    expect(shellStatus('[timed out after 5 s; the process tree was killed; killed by SIGTERM]\npartial')).toEqual({ exitCode: null, note: 'timed out after 5 s; the process tree was killed; killed by SIGTERM', body: 'partial' });148    expect(shellStatus('[output exceeded 1048576 bytes; the command was killed and the output cut; exit code 143]\nx')).toMatchObject({ exitCode: 143, note: 'output exceeded 1048576 bytes; the command was killed and the output cut' });149    expect(shellStatus('exit code 3 printed by the command')).toEqual({ exitCode: null, note: null, body: 'exit code 3 printed by the command' });150    expect(toolView(step(7, { status: 'started', result: null })).ok).toBeNull();151    expect(toolView(step(8, { status: 'failed', result: null, error: { message: 'nope' } }))).toMatchObject({ ok: false, output: 'nope' });152    expect(toolView(step(9, { result: 'plain string' })).output).toBe('plain string');153  });154});155156describe('format', () => {157  it('never rounds a small cost to $0.00', () => {158    expect(fmtUsd(0)).toBe('$0.00');159    expect(fmtUsd(0.0042)).toBe('$0.0042');160    expect(fmtUsd(0.044)).toBe('$0.044');161    expect(fmtUsd(12.5)).toBe('$12.50');162    expect(fmtUsd(null)).toBe('—');163    expect(fmtUsd(60, { compact: true })).toBe('$60.00');164  });165  it('formats tokens, durations and elapsed time', () => {166    expect(fmtTokens(950)).toBe('950');167    expect(fmtTokens(3400)).toBe('3.4k');168    expect(fmtTokens(2_500_000)).toBe('2.50M');169    expect(fmtDuration(75)).toBe('1 min 15 s');170    expect(elapsedSec(null, null)).toBeNull();171    expect(elapsedSec('2026-11-10T10:00:00Z', '2026-11-10T10:01:00Z')).toBe(60);172  });173});174
Files main
Menu

Theme