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:
@@ -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
@@ -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' } } : {}),
|
||||
},
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user