D0: multi-turn complete() — optional messages[] for the director engine

Add an optional `messages` array to CompleteOptions so the upcoming AI DM /
AI Player "director" can carry a multi-turn transcript. A single `turns()`
helper feeds both the Anthropic and OpenAI request builders:

- No `messages` → byte-identical to today's single `[{role:'user',...}]` body,
  so all existing single-shot callers are untouched.
- With `messages` → enforces the shared provider invariants (first turn must be
  `user`; a trailing `assistant` prefill gets a "Continue." user turn appended,
  which Opus 4.6+ otherwise 400s on).

Regression tests cover both providers, the byte-identical no-messages path, and
the alternation guards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 02:37:04 +02:00
parent 4ce7a90534
commit 375e22ad6f
3 changed files with 87 additions and 3 deletions
+62
View File
@@ -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 () => {
+19 -2
View File
@@ -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' } } : {}),
},
+6 -1
View File
@@ -32,8 +32,13 @@ export type LlmResult<T> =
export interface CompleteOptions<T = unknown> {
/** 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. */