- llm.ts: LlmClient.chatWithTools — bounded OpenAI function-calling loop (default 6 tool rounds, then one forced no-tools request); shares the retry/backoff/reasoning-effort machinery with chat(); tools are only added to the request body when non-empty, so plain chat() bodies and their tests are unchanged; a tool-call response is a success (never a truncation); malformed tool arguments become an error tool-message. - engine.ts: GET_NEIGHBOURS_TOOL + makeNeighboursExecutor (degrades unknown tools/args/keys without throwing); playerFrontier (sorted, deduped, other-nation-owned only); buildSystemPrompt gains a tool variant (contiguity rules, frontier hint) used only when the tool is actually sent; buildUserPrompt emits a frontier field whenever a resolver is given; runTurn takes getNeighbours and uses chatWithTools when both the resolver and the client's capability are present, falling back to plain chat() otherwise. - applyTurnResult: snapshot-first sole-ownership algorithm — a region key transfers only with exactly one claimant and exactly one previous owner (different from the gainer); double claims and pre-existing double ownership are left untouched; lone losses are no-ops; every key has 0 or 2+ -> 1 owner post-turn, never orphaned or duplicated. - stub.ts: deterministic one-region contiguous conquest per turn from the prompt's frontier field (bypasses the tool loop; empty frontier -> no transfer, all existing assertions stay stable). - regions.ts: optional region-adjacency.json load; hasAdjacency() and neighboursOf(keys) (union, deduped, queried excluded, sorted). - index.ts: jump route passes the adjacency resolver when loaded. - tests: chatWithTools protocol (echo, executor args, bounded rounds, malformed args, retry), tool-path runTurn wiring + fallback, playerFrontier, executor, applyTurnResult invariants, neighboursOf/ hasAdjacency (105 unit tests, 14 e2e — all green).
390 lines
15 KiB
TypeScript
390 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { createLlmClient, DEFAULT_MAX_TOKENS } from './llm';
|
|
|
|
function json(body: unknown) {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => body,
|
|
text: async () => JSON.stringify(body),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
function httpError(status: number, text = 'upstream down') {
|
|
return {
|
|
ok: false,
|
|
status,
|
|
text: async () => text,
|
|
json: async () => ({}),
|
|
} as unknown as Response;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe('createLlmClient (injectable transport)', () => {
|
|
it('chat posts model+messages to {base}/chat/completions and returns content', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return json({ choices: [{ message: { content: 'hello back' } }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1/', model: 'm1' });
|
|
const out = await llm.chat([{ role: 'user', content: 'hi' }], { maxTokens: 10, temperature: 0.5 });
|
|
expect(out).toBe('hello back');
|
|
expect(sent[0].url).toBe('http://x/v1/chat/completions'); // trailing slash normalized
|
|
const body = JSON.parse(sent[0].init!.body as string);
|
|
// The wire key is `max_tokens` (snake_case), never camelCase `maxTokens`.
|
|
expect(body).toEqual({
|
|
model: 'm1',
|
|
messages: [{ role: 'user', content: 'hi' }],
|
|
temperature: 0.5,
|
|
max_tokens: 10,
|
|
reasoning_effort: 'low',
|
|
});
|
|
// No auth header: the live vLLM endpoint requires no API key.
|
|
expect(sent[0].init!.headers).toEqual({ 'content-type': 'application/json' });
|
|
});
|
|
|
|
it('chat uses a generous default max_tokens when the caller omits it', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return json({ choices: [{ message: { content: 'ok' } }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
|
|
await llm.chat([{ role: 'user', content: 'hi' }]);
|
|
const body = JSON.parse(sent[0].init!.body as string);
|
|
expect(body.max_tokens).toBe(DEFAULT_MAX_TOKENS);
|
|
expect(body.max_tokens).toBe(4000);
|
|
});
|
|
|
|
it('chat sends reasoning_effort "low" by default', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return json({ choices: [{ message: { content: 'ok' } }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm1' });
|
|
await llm.chat([{ role: 'user', content: 'hi' }]);
|
|
const body = JSON.parse(sent[0].init!.body as string);
|
|
expect(body.reasoning_effort).toBe('low');
|
|
});
|
|
|
|
it('chat omits reasoning_effort when it is "off" or empty', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return json({ choices: [{ message: { content: 'ok' } }] });
|
|
};
|
|
for (const effort of ['off', 'OFF', '']) {
|
|
sent.length = 0;
|
|
const llm = createLlmClient({
|
|
transport,
|
|
apiBase: 'http://x/v1',
|
|
model: 'm1',
|
|
reasoningEffort: effort,
|
|
});
|
|
await llm.chat([{ role: 'user', content: 'hi' }]);
|
|
const body = JSON.parse(sent[0].init!.body as string);
|
|
expect(body.reasoning_effort, `effort=${JSON.stringify(effort)}`).toBeUndefined();
|
|
}
|
|
});
|
|
|
|
it('listModels returns data[].id from GET {base}/models', async () => {
|
|
const transport = async (url: string) => {
|
|
expect(url).toBe('http://x/v1/models');
|
|
return json({ data: [{ id: 'a' }, { id: 'b' }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
expect(await llm.listModels()).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('chat retries 5xx and succeeds once the transport recovers (500, 500 → 200)', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
if (sent.length < 3) return httpError(500);
|
|
return json({ choices: [{ message: { content: 'recovered' } }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
const out = await llm.chat([{ role: 'user', content: 'hi' }]);
|
|
expect(out).toBe('recovered');
|
|
expect(sent).toHaveLength(3); // two failures, then success on the third attempt
|
|
});
|
|
|
|
it('chat gives up after exactly 3 attempts when the transport always returns 5xx', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return httpError(500, 'still down');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 500/);
|
|
expect(sent).toHaveLength(3);
|
|
});
|
|
|
|
it('chat does not retry on 4xx (deterministic client error → first attempt only)', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return httpError(400, 'bad request');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 400/);
|
|
expect(sent).toHaveLength(1);
|
|
});
|
|
|
|
it('chat retries a null content (truncation) and then succeeds (null → content)', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
if (sent.length === 1) return json({ choices: [{ message: { content: null } }] });
|
|
return json({ choices: [{ message: { content: 'real content' } }] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
const out = await llm.chat([{ role: 'user', content: 'hi' }]);
|
|
expect(out).toBe('real content');
|
|
expect(sent).toHaveLength(2);
|
|
});
|
|
|
|
it('chat rejects with the HTTP status after 3 attempts when 5xx never recovers', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return httpError(503);
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 503/);
|
|
expect(sent).toHaveLength(3);
|
|
});
|
|
|
|
it('chat rejects with a content error after 3 attempts when content is never present', async () => {
|
|
const sent: { url: string; init?: RequestInit }[] = [];
|
|
const transport = async (url: string, init?: RequestInit) => {
|
|
sent.push({ url, init });
|
|
return json({ choices: [] });
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm', retryBaseDelayMs: 1 });
|
|
await expect(llm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/no choices/);
|
|
expect(sent).toHaveLength(3);
|
|
});
|
|
|
|
it('chat logs one line on success and one on final failure', async () => {
|
|
const logSpy = vi.spyOn(console, 'log');
|
|
const errorSpy = vi.spyOn(console, 'error');
|
|
|
|
const okTransport = async () =>
|
|
json({ choices: [{ message: { content: 'ok' }, finish_reason: 'stop' }] });
|
|
const okLlm = createLlmClient({ transport: okTransport, apiBase: 'http://x/v1', model: 'm' });
|
|
await okLlm.chat([{ role: 'user', content: 'hi' }]);
|
|
expect(logSpy).toHaveBeenCalledWith(
|
|
'[llm] chat ok',
|
|
expect.objectContaining({ ms: expect.any(Number), finishReason: 'stop' }),
|
|
);
|
|
|
|
const failSent: { url: string; init?: RequestInit }[] = [];
|
|
const failTransport = async (url: string, init?: RequestInit) => {
|
|
failSent.push({ url, init });
|
|
return httpError(502, 'bad gateway');
|
|
};
|
|
const failLlm = createLlmClient({
|
|
transport: failTransport,
|
|
apiBase: 'http://x/v1',
|
|
model: 'm',
|
|
retryBaseDelayMs: 1,
|
|
});
|
|
await expect(failLlm.chat([{ role: 'user', content: 'hi' }])).rejects.toThrow(/HTTP 502/);
|
|
expect(failSent).toHaveLength(3);
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[llm] chat failed',
|
|
expect.objectContaining({
|
|
attempts: 3,
|
|
lastStatus: 502,
|
|
message: expect.stringContaining('HTTP 502'),
|
|
totalMs: expect.any(Number),
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('createLlmClient.chatWithTools (function-calling loop, v0.21 #16 P2)', () => {
|
|
const tool = {
|
|
type: 'function' as const,
|
|
function: {
|
|
name: 'getNeighbours',
|
|
description: 'list bordering region keys',
|
|
parameters: { type: 'object' },
|
|
},
|
|
};
|
|
|
|
function toolCallResponse(id: string, name: string, args: string) {
|
|
return json({
|
|
choices: [
|
|
{
|
|
finish_reason: 'tool_calls',
|
|
message: {
|
|
content: null,
|
|
tool_calls: [{ id, type: 'function', function: { name, arguments: args } }],
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
function contentResponse(content: string) {
|
|
return json({ choices: [{ finish_reason: 'stop', message: { content } }] });
|
|
}
|
|
|
|
it('sends tools in the first request; echoes the assistant tool_calls + tool result; the executor gets parsed args', async () => {
|
|
const sent: string[] = [];
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
|
return contentResponse('final answer');
|
|
};
|
|
const executorCalls: Array<{ name: string; args: Record<string, unknown> }> = [];
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
(name, args) => {
|
|
executorCalls.push({ name, args });
|
|
return JSON.stringify({ neighbours: ['b'] });
|
|
},
|
|
);
|
|
expect(out).toBe('final answer');
|
|
expect(sent).toHaveLength(2);
|
|
// request 1: the tools array is present, history is just the initial message
|
|
const body1 = JSON.parse(sent[0]);
|
|
expect(body1.tools).toEqual([tool]);
|
|
expect(body1.messages).toEqual([{ role: 'user', content: 'hi' }]);
|
|
// request 2: the assistant tool-call and the tool result are echoed back
|
|
const body2 = JSON.parse(sent[1]);
|
|
expect(body2.tools).toEqual([tool]);
|
|
expect(body2.messages).toEqual([
|
|
{ role: 'user', content: 'hi' },
|
|
{
|
|
role: 'assistant',
|
|
content: null,
|
|
tool_calls: [
|
|
{
|
|
id: 'call_1',
|
|
type: 'function',
|
|
function: { name: 'getNeighbours', arguments: '{"regionKeys":["a"]}' },
|
|
},
|
|
],
|
|
},
|
|
{ role: 'tool', tool_call_id: 'call_1', content: JSON.stringify({ neighbours: ['b'] }) },
|
|
]);
|
|
// the executor received the parsed (object) arguments, not the raw string
|
|
expect(executorCalls).toEqual([{ name: 'getNeighbours', args: { regionKeys: ['a'] } }]);
|
|
});
|
|
|
|
it('a content-only response ends in one round (the executor is never called)', async () => {
|
|
const sent: string[] = [];
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
return contentResponse('done');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
() => {
|
|
throw new Error('the executor must not be called');
|
|
},
|
|
);
|
|
expect(out).toBe('done');
|
|
expect(sent).toHaveLength(1);
|
|
});
|
|
|
|
it('bounds the tool rounds: maxRounds tool-call rounds, then one final no-tools request', async () => {
|
|
const sent: string[] = [];
|
|
let n = 0;
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
n += 1;
|
|
if (n <= 2) return toolCallResponse(`call_${n}`, 'getNeighbours', '{}');
|
|
return contentResponse('committed');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
() => JSON.stringify({ neighbours: [] }),
|
|
{ maxRounds: 2 },
|
|
);
|
|
expect(out).toBe('committed');
|
|
expect(sent).toHaveLength(3); // 2 tool rounds + the forced final request
|
|
expect(JSON.parse(sent[2]).tools).toBeUndefined(); // the final request has no tools
|
|
});
|
|
|
|
it('malformed tool arguments produce an "invalid tool arguments" tool message (no throw)', async () => {
|
|
const sent: string[] = [];
|
|
let n = 0;
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
n += 1;
|
|
if (n === 1) return toolCallResponse('call_1', 'getNeighbours', '{not valid json');
|
|
return contentResponse('recovered');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
() => {
|
|
throw new Error('the executor must not be called on malformed args');
|
|
},
|
|
);
|
|
expect(out).toBe('recovered');
|
|
const body2 = JSON.parse(sent[1]);
|
|
const toolMsg = body2.messages.find((m: { role?: string }) => m.role === 'tool');
|
|
expect(toolMsg).toBeDefined();
|
|
expect(toolMsg.content).toMatch(/invalid tool arguments/);
|
|
});
|
|
|
|
it('retries a tool-call round on transient failure and still resolves the tool (500 → tool_calls → content)', async () => {
|
|
const sent: string[] = [];
|
|
let n = 0;
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
n += 1;
|
|
if (n === 1) return httpError(500);
|
|
if (n === 2) return toolCallResponse('call_1', 'getNeighbours', '{"regionKeys":["a"]}');
|
|
return contentResponse('ok');
|
|
};
|
|
const llm = createLlmClient({
|
|
transport,
|
|
apiBase: 'http://x/v1',
|
|
model: 'm',
|
|
retryBaseDelayMs: 1,
|
|
});
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
() => JSON.stringify({ neighbours: [] }),
|
|
);
|
|
expect(out).toBe('ok');
|
|
expect(sent).toHaveLength(3);
|
|
});
|
|
|
|
it('a tool-call response is never treated as a truncation failure (content null is fine)', async () => {
|
|
const sent: string[] = [];
|
|
const transport = async (_url: string, init?: RequestInit) => {
|
|
sent.push(String(init?.body ?? ''));
|
|
if (sent.length === 1) return toolCallResponse('call_1', 'getNeighbours', '{}');
|
|
return contentResponse('ok');
|
|
};
|
|
const llm = createLlmClient({ transport, apiBase: 'http://x/v1', model: 'm' });
|
|
const out = await llm.chatWithTools!(
|
|
[{ role: 'user', content: 'hi' }],
|
|
[tool],
|
|
() => JSON.stringify({ neighbours: [] }),
|
|
);
|
|
expect(out).toBe('ok');
|
|
expect(sent).toHaveLength(2); // no retry of the first (successful) round
|
|
});
|
|
});
|