Files
gbrain/test/gateway-model-messages.test.ts
T
+5 86962b242b fix(gateway): consolidate tool-loop resume + provider fixes (fix-wave A) (#2820)
Collector branch superseding the gateway tool-loop duplicate cluster and
adjacent provider fixes. Re-implemented from the best of each PR (deduped by
content, not file-overlap); every fix carries test coverage.

Gateway tool-loop resume (supersedes #1934 #2062 #2065 #2112 #2274 #2487
#2336 #2257 #2499 #2491, test #2063):
- toolLoop now persists the tool-result user turn per round (onToolResultTurn),
  so a resumed subagent job reloads a balanced transcript instead of dangling
  assistant tool-calls that non-Anthropic providers reject with
  AI_MissingToolResultsError.
- runSubagentViaGateway reconciles an already-corrupted transcript on resume:
  it heals every dangling assistant tool-call turn (not just the tail) from the
  settled subagent_tool_executions rows, re-dispatching idempotent-pending
  tools and throwing on non-idempotent, mirroring the legacy Anthropic path.
  Terminal early-return for a transcript that already reached end_turn.
- repairToolPairing() is a last-resort normalization at the chat() boundary
  (from #2336): back-fills error stubs for any assistant tool-call still
  unanswered (partial turns, provider-duplicated/dropped IDs on local models,
  length-truncated batches). No-op on balanced input.
- toModelMessages is Date-safe (Postgres timestamptz -> ISO via a JSON round
  trip at the SDK boundary, never a ::jsonb cast; degrades bigint/circular to a
  string instead of throwing) and drops non-string text blocks reasoning models
  emit that AI SDK v6 rejects (#2488).

Adjacent provider fixes:
- Model-aware default max output tokens: thinking-by-default Claude 5 models get
  headroom (gateway 32000, think 16000) while everything else stays 4096/4000,
  so DeepSeek/OpenAI subagents don't exceed provider caps (#2614 #2806).
- DeepSeek: promote reasoning_content into content when content is empty, via a
  fail-open recipe fetch shim (#2617).
- OpenRouter: map openrouter_api_key (config + env) into OPENROUTER_API_KEY
  through buildGatewayConfig; register agent.use_gateway_loop, zeroentropy and
  openrouter keys in KNOWN_CONFIG_KEYS so `config set` accepts them (#2572,
  config key from #2112).

Preserves JSONB (no JSON.stringify into ::jsonb), engine parity, source
isolation, and trust-boundary invariants. Verified with the gbrain-pr-test-env
consumer matrix (clawlancer/Postgres, gstack + hivemindos/PGLite) baseline-FAIL
-> candidate-PASS on the same repro, plus bun run verify (31/31) and 439
targeted unit + e2e tests.

Co-authored-by: Sinabina <sinabina@Sinabinas-MacBook-Pro-4.local>
Co-authored-by: thomaskong119 <thomaskong119@hotmail.com>
Co-authored-by: maxpetrusenkoagent <max.petrusenko.agent@gmail.com>
Co-authored-by: brettdavies <brettdavies@users.noreply.github.com>
Co-authored-by: ivandebot <187176982+ivandebot@users.noreply.github.com>
Co-authored-by: Rafael Reis <rafael.reis@contabilizei.com.br>
Co-authored-by: fbal23 <fbal.public@gmail.com>
Co-authored-by: javieraldape <javieraldape@users.noreply.github.com>
Co-authored-by: David Carolan <david@joyrestart.com>
Co-authored-by: Masashi-Ono0611 <masashi.ono.0611@gmail.com>
Co-authored-by: spiky02plateau <155588579+spiky02plateau@users.noreply.github.com>
Co-authored-by: psam-717 <mphilannorbah@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 16:31:53 -07:00

181 lines
7.0 KiB
TypeScript

/**
* Pins `toModelMessages` — the gbrain ChatMessage[] → AI SDK v6 ModelMessage[]
* converter. v6 tightened ModelMessage validation: tool results must ride on a
* dedicated `role:'tool'` message with a structured `{type,value}` output part,
* not a `role:'user'` message with a bare-value tool-result block (which is how
* gbrain's toolLoop pushes them). Without this conversion every multi-turn tool
* loop — skillopt rollouts AND production subagent jobs — throws "messages do
* not match the ModelMessage[] schema" the moment the model calls a tool.
*
* Surfaced by the SkillOpt real-LLM eval (Track B). These cases pin the exact
* v6 shapes that `generateText` accepts (verified against AI SDK 6.0.174).
*/
import { describe, test, expect } from 'bun:test';
import { toModelMessages, type ChatMessage } from '../src/core/ai/gateway.ts';
describe('toModelMessages — v6 ModelMessage shape', () => {
test('string content passes through unchanged', () => {
const msgs: ChatMessage[] = [{ role: 'user', content: 'hello' }];
expect(toModelMessages(msgs)).toEqual([{ role: 'user', content: 'hello' }]);
});
test('assistant text block maps to {type:text,text}', () => {
const msgs: ChatMessage[] = [
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
];
expect(toModelMessages(msgs)).toEqual([
{ role: 'assistant', content: [{ type: 'text', text: 'hi' }] },
]);
});
test('assistant tool-call block keeps {toolCallId,toolName,input}', () => {
const msgs: ChatMessage[] = [
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'x' } }],
},
];
expect(toModelMessages(msgs)).toEqual([
{
role: 'assistant',
content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'x' } }],
},
]);
});
test('tool-result on a user-role message becomes role:tool with json output', () => {
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { hits: 0 } }],
},
];
expect(toModelMessages(msgs)).toEqual([
{
role: 'tool',
content: [
{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { type: 'json', value: { hits: 0 } } },
],
},
]);
});
test('string tool-result output becomes {type:text,value}', () => {
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'echo', output: 'done' }],
},
];
expect(toModelMessages(msgs)).toEqual([
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'echo', output: { type: 'text', value: 'done' } }],
},
]);
});
test('errored tool-result becomes {type:error-text,value}', () => {
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { msg: 'boom' }, isError: true }],
},
];
expect(toModelMessages(msgs)).toEqual([
{
role: 'tool',
content: [
{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { type: 'error-text', value: '{"msg":"boom"}' } },
],
},
]);
});
test('null tool-result output is preserved as json null (not dropped)', () => {
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'noop', output: null }],
},
];
expect(toModelMessages(msgs)).toEqual([
{
role: 'tool',
content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'noop', output: { type: 'json', value: null } }],
},
]);
});
test('Date in tool-result json output serializes to ISO string (Postgres timestamptz)', () => {
// node-postgres returns timestamptz columns as JS Date; AI SDK v6's
// JSONValue schema rejects a raw Date, dead-lettering the tool loop.
const msgs: ChatMessage[] = [
{
role: 'user',
content: [{
type: 'tool-result',
toolCallId: 'c1',
toolName: 'brain_get_page',
output: { rows: [{ updated_at: new Date('2026-06-26T06:56:59.000Z'), nested: { created_at: new Date('2026-01-02T03:04:05.000Z') } }] },
}],
},
];
const out = toModelMessages(msgs) as any[];
const value = out[0].content[0].output.value;
expect(out[0].content[0].output.type).toBe('json');
expect(value.rows[0].updated_at).toBe('2026-06-26T06:56:59.000Z');
expect(value.rows[0].nested.created_at).toBe('2026-01-02T03:04:05.000Z');
// No Date instance survives (would throw in AI SDK v6).
expect(value.rows[0].updated_at instanceof Date).toBe(false);
});
test('non-string text block is dropped (reasoning-model null-text guard)', () => {
// DeepSeek v4 / reasoning models can emit text:null/undefined thinking
// parts; AI SDK v6 rejects them. Dropped here; tool-call sibling kept.
const msgs: ChatMessage[] = [
{
role: 'assistant',
content: [
{ type: 'text', text: null as unknown as string },
{ type: 'text', text: undefined as unknown as string },
{ type: 'text', text: 'kept' },
{ type: 'text', text: '' }, // empty string is valid — kept
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
],
},
];
const out = toModelMessages(msgs) as any[];
expect(out[0].content).toEqual([
{ type: 'text', text: 'kept' },
{ type: 'text', text: '' },
{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: {} },
]);
});
test('errored tool-result never throws on circular/bigint output (safeStringify)', () => {
const circular: any = {};
circular.self = circular;
const msgs: ChatMessage[] = [
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'x', output: circular, isError: true }] },
];
const out = toModelMessages(msgs) as any[];
expect(out[0].content[0].output.type).toBe('error-text');
expect(typeof out[0].content[0].output.value).toBe('string');
});
test('full multi-turn conversation: user → assistant(tool-call) → tool(result)', () => {
const msgs: ChatMessage[] = [
{ role: 'user', content: 'find widget' },
{ role: 'assistant', content: [{ type: 'tool-call', toolCallId: 'c1', toolName: 'search', input: { query: 'widget' } }] },
{ role: 'user', content: [{ type: 'tool-result', toolCallId: 'c1', toolName: 'search', output: { hits: 0 } }] },
];
const out = toModelMessages(msgs);
expect(out).toHaveLength(3);
expect((out[0] as any).role).toBe('user');
expect((out[1] as any).role).toBe('assistant');
expect((out[2] as any).role).toBe('tool');
expect((out[2] as any).content[0].output).toEqual({ type: 'json', value: { hits: 0 } });
});
});