/** * Tests for recovering tool calls a model wrote as text. * * The bug these lock down: deepseek-chat writes its call envelope into the * text channel with its own separators (`<||DSML|| invoke name="Bash">`), * so no tool ever ran and the user was shown the markup instead of an answer * (2026-09-19). Matching is by shape — a new prefix must not defeat it. */ import { describe, it, expect } from 'vitest'; import { parseTextToolCalls, hasTextToolCalls, coerceArgs, resolveToolName, type SchemaLike } from './text-tool-calls.js'; /** The exact bytes deepseek-chat emitted, taken from ~/.crewly/chat.db. */ const DSML = [ 'Let me check the repo.', '<||DSML|| calls>', '<||DSML|| invoke name="bash_exec">', '<||DSML|| parameter name="command" string="true">git status --short', '<||DSML|| parameter name="timeout" string="true">5000', '', '', ].join('\n'); /** A minimal Zod-style schema. */ function schema(check: (v: Record) => boolean): SchemaLike { return { safeParse: (v: unknown) => (check(v as Record) ? { success: true, data: v } : { success: false, error: { message: 'bad args' } }) }; } describe('parseTextToolCalls', () => { it('recovers the deepseek envelope, prefix and all, and leaves the prose', () => { const { calls, text } = parseTextToolCalls(DSML); expect(calls).toEqual([ { toolName: 'bash_exec', args: { command: 'git status --short', timeout: '5000' } }, ]); expect(text).toBe('Let me check the repo.'); }); it('recovers the plain Claude-style envelope too', () => { const { calls } = parseTextToolCalls('a.ts'); expect(calls).toEqual([{ toolName: 'read_file', args: { path: 'a.ts' } }]); }); it('recovers every call in a batch, in order', () => { const { calls } = parseTextToolCalls( '12', ); expect(calls.map((c) => c.toolName)).toEqual(['a', 'b']); expect(calls[1].args).toEqual({ y: '2' }); }); it('keeps a multi-line value intact, including its own angle brackets', () => { const { calls } = parseTextToolCalls( 'line 1\nif (a < b) { go(); }\nline 3', ); expect(calls[0].args.content).toBe('line 1\nif (a < b) { go(); }\nline 3'); }); it('salvages a call the model never closed', () => { const { calls, text } = parseTextToolCalls('Working.\nnpm test'); expect(calls).toEqual([{ toolName: 'bash_exec', args: { command: 'npm test' } }]); expect(text).toBe('Working.'); }); it('leaves a fenced example alone — it is documentation, not a call', () => { const raw = 'Here is the syntax:\n\n```\nls\n```'; const { calls, text } = parseTextToolCalls(raw); expect(calls).toEqual([]); expect(text).toBe(raw); }); it('finds nothing in ordinary prose, and does not touch it', () => { for (const prose of ['', 'The build recalls the cached layer.', 'Use in the docs? no.']) { expect(parseTextToolCalls(prose).calls).toEqual([]); } expect(parseTextToolCalls('The build recalls the cached layer.').text).toBe('The build recalls the cached layer.'); }); it('ignores an invoke tag with no name rather than inventing one', () => { expect(parseTextToolCalls('1').calls).toEqual([]); }); }); describe('hasTextToolCalls', () => { it('is true for any prefix and false for prose', () => { expect(hasTextToolCalls(DSML)).toBe(true); expect(hasTextToolCalls('')).toBe(true); expect(hasTextToolCalls('nothing to see')).toBe(false); }); }); describe('coerceArgs', () => { it('passes strings through when the schema accepts them', () => { const out = coerceArgs({ command: '5' }, schema((v) => typeof v.command === 'string')); expect(out).toEqual({ args: { command: '5' } }); }); it('parses JSON-looking values only when the schema refuses the strings', () => { const out = coerceArgs({ timeout: '5000', on: 'true', tags: '["a"]' }, schema((v) => typeof v.timeout === 'number')); expect(out.args).toEqual({ timeout: 5000, on: true, tags: ['a'] }); expect(out.error).toBeUndefined(); }); it('coerces with no schema to give the tool its best shot', () => { expect(coerceArgs({ n: '3', s: 'hello' }).args).toEqual({ n: 3, s: 'hello' }); }); it('reports the schema complaint instead of calling a tool with bad arguments', () => { const out = coerceArgs({ command: 'ls' }, schema((v) => typeof v.path === 'string')); expect(out.error).toBe('bad args'); }); it('keeps a value that only looks like JSON', () => { expect(coerceArgs({ command: '{ not json' }).args).toEqual({ command: '{ not json' }); }); }); describe('resolveToolName', () => { const available = ['bash_exec', 'read_file', 'write_file', 'edit_file', 'glob', 'grep', 'delegate_task', 'git_status']; it('takes an exact name unchanged', () => { expect(resolveToolName('bash_exec', available)).toBe('bash_exec'); }); it('matches across casing and separators, so GitStatus finds git_status', () => { for (const written of ['GitStatus', 'git-status', 'GIT_STATUS', 'gitStatus']) { expect(resolveToolName(written, available)).toBe('git_status'); } }); it("resolves the names another harness uses — the live failure was 'Bash'", () => { expect(resolveToolName('Bash', available)).toBe('bash_exec'); expect(resolveToolName('Read', available)).toBe('read_file'); expect(resolveToolName('Write', available)).toBe('write_file'); expect(resolveToolName('Edit', available)).toBe('edit_file'); expect(resolveToolName('Task', available)).toBe('delegate_task'); expect(resolveToolName('Search', available)).toBe('grep'); }); it('does not invent a tool the run does not have', () => { expect(resolveToolName('Bash', ['read_file'])).toBeUndefined(); expect(resolveToolName('deploy_to_prod', available)).toBeUndefined(); expect(resolveToolName('', available)).toBeUndefined(); }); });