Pass 1 — ask_user_input correlation port (messages.ts:478, :549):
- The two correlation queries that backed the elicitation flow used to scan
messages.tool_calls and messages.tool_results JSON columns directly. They
now JOIN message_parts on payload->>'id' (for the caller assistant) and
payload->>'tool_call_id' (for the pending tool row). Semantics preserved:
ORDER BY m.created_at DESC LIMIT 1 still picks the latest issuance, the
already-answered 409 guard now reads payload.output, and the UPDATE +
parts replace inside sql.begin is unchanged from v1.13.0.
- Pre-v1.13.0 history has no parts rows and is unreachable to this lookup
path (404). Acceptable per dispatch decision — no pending elicitation
from before v1.13.0 will still be open. JSON-column fallback can land as
a hotfix if it ever surfaces.
Pass 2 — reasoning_parts wired end-to-end:
- types.ts/StreamResult gains `reasoning: string`. stream-phase.ts accumulates
reasoning-delta text per stream (replacing the v1.13.1-A counter-only
diagnostic) and returns it on the result.
- parts.ts/partsFromAssistantMessage gains an optional `reasoning` param.
When present it emits a kind='reasoning' part at sequence 0, ahead of
the text and tool_call parts.
- error-handler.ts/finalizeCompletion and tool-phase.ts/executeToolPhase
both thread result.reasoning into the dual-write call so reasoning-channel
models (qwen3.6) get persistent reasoning rows.
- payload.ts: loadContext SELECT pulls reasoning_parts from the v1.13.1-B
view; OpenAiMessage gains an optional `reasoning` field; buildMessagesPayload
collapses reasoning_parts into a single string per assistant message.
- stream-phase.ts/toModelMessages converts assistant messages with reasoning
into an AI SDK ModelMessage content array starting with a ReasoningPart,
matching the @ai-sdk/provider-utils AssistantContent union. Reasoning
models can now replay prior reasoning context across tool-call boundaries.
- types/api.ts and apps/web/src/api/types.ts Message interface gain
reasoning_parts (optional, nullable). Frontend doesn't render this yet —
field reserved for a v1.14 UI surface.
Tests: 2 new in parts.test.ts cover reasoning-at-sequence-0 with and
without text content. 172 tests pass (170 prior + 2 new).
Smoke verified against the live container:
- A reasoning-prompt ("walk through 17 × 23 step by step") produced one
message with kind='reasoning' (361 chars) at sequence 0 and kind='text'
(429 chars) at sequence 1. Adapter log confirmed reasoning capture.
- The new correlation SQL was validated against existing tool_call /
tool_result parts: returns the expected message_id + payload shape with
pending state correctly identified via payload.output IS NULL.
- ask_user_input end-to-end through the UI is Sam's smoke — the Prompt
Builder agent does not always trigger ask_user_input for these prompts,
so synthetic verification via SQL substituted for traffic-driven cover.
Annotation: the v1.13.1-A abort-throw site in stream-phase.ts got a
one-liner comment ("AI SDK v6 fullStream returns normally on abort; check
signal explicitly.") to prevent a future refactor removing it.
v1.13.2 drops the dual-write + the JSON columns + collapses the view.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
122 lines
4.2 KiB
TypeScript
122 lines
4.2 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { partsFromAssistantMessage, partsFromToolMessage } from '../inference/parts.js';
|
|
import type { ToolCall, ToolResult } from '../../types/api.js';
|
|
|
|
describe('partsFromAssistantMessage', () => {
|
|
it('emits one text part for content-only assistant', () => {
|
|
const parts = partsFromAssistantMessage({ content: 'hello world', tool_calls: null });
|
|
expect(parts).toHaveLength(1);
|
|
expect(parts[0]).toEqual({
|
|
sequence: 0,
|
|
kind: 'text',
|
|
payload: { text: 'hello world' },
|
|
});
|
|
});
|
|
|
|
it('emits one tool_call part for empty-content + single tool_call', () => {
|
|
const tc: ToolCall = { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } };
|
|
const parts = partsFromAssistantMessage({ content: '', tool_calls: [tc] });
|
|
expect(parts).toHaveLength(1);
|
|
expect(parts[0]).toEqual({
|
|
sequence: 0,
|
|
kind: 'tool_call',
|
|
payload: { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } },
|
|
});
|
|
});
|
|
|
|
it('emits text then tool_call parts in order when both present', () => {
|
|
const tc: ToolCall = { id: 'call_2', name: 'grep', args: { pattern: 'foo' } };
|
|
const parts = partsFromAssistantMessage({ content: 'let me search', tool_calls: [tc] });
|
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
|
[0, 'text'],
|
|
[1, 'tool_call'],
|
|
]);
|
|
});
|
|
|
|
it('preserves tool_call order with multiple calls', () => {
|
|
const calls: ToolCall[] = [
|
|
{ id: 'a', name: 'list_dir', args: { path: '.' } },
|
|
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
|
|
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
|
|
];
|
|
const parts = partsFromAssistantMessage({ content: '', tool_calls: calls });
|
|
expect(parts).toHaveLength(3);
|
|
expect(parts.map((p) => p.payload)).toEqual([
|
|
{ id: 'a', name: 'list_dir', args: { path: '.' } },
|
|
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
|
|
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
|
|
]);
|
|
expect(parts.map((p) => p.sequence)).toEqual([0, 1, 2]);
|
|
});
|
|
|
|
it('returns empty array for empty content + null tool_calls', () => {
|
|
expect(partsFromAssistantMessage({ content: '', tool_calls: null })).toEqual([]);
|
|
});
|
|
|
|
it('v1.13.1-C: reasoning lands at sequence 0 before text + tool_calls', () => {
|
|
const tc: ToolCall = { id: 'call_r', name: 'view_file', args: { path: 'x.ts' } };
|
|
const parts = partsFromAssistantMessage({
|
|
content: 'inspecting now',
|
|
tool_calls: [tc],
|
|
reasoning: 'user asked about x.ts; I should view it',
|
|
});
|
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
|
[0, 'reasoning'],
|
|
[1, 'text'],
|
|
[2, 'tool_call'],
|
|
]);
|
|
expect(parts[0]!.payload).toEqual({
|
|
text: 'user asked about x.ts; I should view it',
|
|
});
|
|
});
|
|
|
|
it('v1.13.1-C: reasoning + empty content + tool_calls preserves seq 0 reasoning', () => {
|
|
const tc: ToolCall = { id: 'call_r2', name: 'grep', args: { pattern: 'foo' } };
|
|
const parts = partsFromAssistantMessage({
|
|
content: '',
|
|
tool_calls: [tc],
|
|
reasoning: 'jumping straight to grep',
|
|
});
|
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
|
[0, 'reasoning'],
|
|
[1, 'tool_call'],
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe('partsFromToolMessage', () => {
|
|
it('emits a single tool_result part at sequence 0', () => {
|
|
const tr: ToolResult = {
|
|
tool_call_id: 'call_1',
|
|
output: { contents: 'console.log(1)' },
|
|
truncated: false,
|
|
};
|
|
const parts = partsFromToolMessage({ tool_results: tr });
|
|
expect(parts).toHaveLength(1);
|
|
expect(parts[0]).toEqual({
|
|
sequence: 0,
|
|
kind: 'tool_result',
|
|
payload: {
|
|
tool_call_id: 'call_1',
|
|
output: { contents: 'console.log(1)' },
|
|
truncated: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('includes error in payload when present', () => {
|
|
const tr: ToolResult = {
|
|
tool_call_id: 'call_2',
|
|
output: null,
|
|
truncated: false,
|
|
error: 'permission denied',
|
|
};
|
|
const parts = partsFromToolMessage({ tool_results: tr });
|
|
expect(parts[0]!.payload).toMatchObject({ error: 'permission denied' });
|
|
});
|
|
|
|
it('returns empty array when tool_results is null', () => {
|
|
expect(partsFromToolMessage({ tool_results: null })).toEqual([]);
|
|
});
|
|
});
|