diff --git a/src/lib/llm/client.test.ts b/src/lib/llm/client.test.ts index 2f11b0f..edd72bc 100644 --- a/src/lib/llm/client.test.ts +++ b/src/lib/llm/client.test.ts @@ -69,6 +69,68 @@ describe('complete — request shapes', () => { }); }); +describe('complete — multi-turn messages', () => { + it('without messages, the Anthropic body is identical to the single-user shape', async () => { + const fn = mockFetch(() => anthropicReply('ok')); + await complete(base, { system: 'sys', user: 'hi' }); + const body = JSON.parse((fn.mock.calls[0]![1].body as string)); + expect(body.messages).toEqual([{ role: 'user', content: 'hi' }]); + }); + + it('without messages, the OpenAI body keeps system + single user', async () => { + const fn = mockFetch(() => openaiReply('ok')); + const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' }; + await complete(cfg, { system: 'sys', user: 'hi' }); + const body = JSON.parse((fn.mock.calls[0]![1].body as string)); + expect(body.messages).toEqual([ + { role: 'system', content: 'sys' }, + { role: 'user', content: 'hi' }, + ]); + }); + + it('passes a transcript through as alternating turns (Anthropic), superseding user', async () => { + const fn = mockFetch(() => anthropicReply('ok')); + await complete(base, { + system: 'sys', user: 'ignored-when-messages-present', + messages: [ + { role: 'user', content: 'I open the door.' }, + { role: 'assistant', content: 'It creaks open.' }, + { role: 'user', content: 'I step through.' }, + ], + }); + const body = JSON.parse((fn.mock.calls[0]![1].body as string)); + expect(body.messages).toEqual([ + { role: 'user', content: 'I open the door.' }, + { role: 'assistant', content: 'It creaks open.' }, + { role: 'user', content: 'I step through.' }, + ]); + }); + + it('prepends a synthetic user turn when the transcript starts with assistant', async () => { + const fn = mockFetch(() => anthropicReply('ok')); + await complete(base, { + system: 'sys', user: 'u', + messages: [{ role: 'assistant', content: 'The tavern is loud.' }, { role: 'user', content: 'I sit.' }], + }); + const body = JSON.parse((fn.mock.calls[0]![1].body as string)); + expect(body.messages[0]).toEqual({ role: 'user', content: '(scene start)' }); + expect(body.messages.at(-1)).toEqual({ role: 'user', content: 'I sit.' }); + }); + + it('appends a Continue. user turn when the transcript ends on assistant', async () => { + const fn = mockFetch(() => openaiReply('ok')); + const cfg: LlmConfig = { ...base, provider: 'openai-compatible', baseUrl: 'https://api.openai.com/v1', model: 'gpt-4o' }; + await complete(cfg, { + system: 'sys', user: 'u', + messages: [{ role: 'user', content: 'Where am I?' }, { role: 'assistant', content: 'A dark wood.' }], + }); + const body = JSON.parse((fn.mock.calls[0]![1].body as string)); + // system first, then the two turns, then the synthetic Continue. + expect(body.messages[0].role).toBe('system'); + expect(body.messages.at(-1)).toEqual({ role: 'user', content: 'Continue.' }); + }); +}); + describe('complete — structured output', () => { const schema = z.object({ name: z.string() }); it('parses JSON wrapped in markdown fences', async () => { diff --git a/src/lib/llm/client.ts b/src/lib/llm/client.ts index c098bb7..aae7209 100644 --- a/src/lib/llm/client.ts +++ b/src/lib/llm/client.ts @@ -81,6 +81,23 @@ interface Request { body: unknown; } +type Turn = { role: 'user' | 'assistant'; content: string }; + +/** + * Resolve the message list both providers send. Collapses to today's single + * `[{role:'user',content:user}]` when no `messages` are supplied, so existing + * callers are byte-identical. When a transcript is given it enforces the shared + * provider invariants: the first turn must be `user`, and the last turn must NOT + * be an `assistant` prefill (Anthropic Opus 4.6+ 400s on that; OpenAI handles it + * poorly) — a trailing assistant turn gets a `"Continue."` user turn appended. + */ +function turns(opts: CompleteOptions): Turn[] { + const src = opts.messages?.length ? opts.messages : [{ role: 'user' as const, content: opts.user }]; + const out: Turn[] = src[0]?.role === 'assistant' ? [{ role: 'user', content: '(scene start)' }, ...src] : [...src]; + if (out[out.length - 1]?.role === 'assistant') out.push({ role: 'user', content: 'Continue.' }); + return out; +} + function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): Request { return { url: `${trimSlash(cfg.baseUrl)}/v1/messages`, @@ -94,7 +111,7 @@ function buildAnthropic(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean model: cfg.model, max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS, system: wantJson ? opts.system + jsonInstruction() : opts.system, - messages: [{ role: 'user', content: opts.user }], + messages: turns(opts), }, }; } @@ -111,7 +128,7 @@ function buildOpenai(cfg: LlmConfig, opts: CompleteOptions, wantJson: boolean): max_tokens: opts.maxTokens ?? DEFAULT_MAX_TOKENS, messages: [ { role: 'system', content: wantJson ? opts.system + jsonInstruction() : opts.system }, - { role: 'user', content: opts.user }, + ...turns(opts), ], ...(wantJson ? { response_format: { type: 'json_object' } } : {}), }, diff --git a/src/lib/llm/types.ts b/src/lib/llm/types.ts index 82e32a2..dc34f45 100644 --- a/src/lib/llm/types.ts +++ b/src/lib/llm/types.ts @@ -32,8 +32,13 @@ export type LlmResult = export interface CompleteOptions { /** System prompt — must lead with the grounding/system constraint. */ system: string; - /** User message. */ + /** User message. Used when `messages` is absent (and as a fallback). */ user: string; + /** Optional multi-turn conversation history. When non-empty it supersedes `user`. + * The provider request enforces the shared invariants (first turn must be `user`; + * no trailing `assistant` prefill — Anthropic Opus 4.6+ rejects it), so callers may + * pass a raw transcript without normalizing alternation themselves. */ + messages?: { role: 'user' | 'assistant'; content: string }[]; /** When provided, structured JSON output is requested and validated against it. * Input is left open (`any`) so schemas using coerce/preprocess/transform — whose * parsed input type differs from T — are accepted; T is pinned to the output. */