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>
96 lines
3.2 KiB
TypeScript
96 lines
3.2 KiB
TypeScript
import type { Sql } from '../../db.js';
|
|
import type { ToolCall, ToolResult } from '../../types/api.js';
|
|
|
|
// v1.13.0: dual-write helper. Every site that writes the legacy
|
|
// messages.tool_calls / messages.tool_results JSON columns calls into here
|
|
// to mirror the same data into message_parts rows. Reads still go to the
|
|
// JSON columns; the swap to parts-as-source-of-truth happens in a later
|
|
// v1.13 dispatch alongside the AI SDK streamText migration.
|
|
|
|
export type PartKind = 'text' | 'tool_call' | 'tool_result' | 'reasoning' | 'step_start';
|
|
|
|
export interface PartInsert {
|
|
message_id: string;
|
|
sequence: number;
|
|
kind: PartKind;
|
|
payload: unknown;
|
|
}
|
|
|
|
export async function insertParts(sql: Sql, parts: PartInsert[]): Promise<void> {
|
|
if (parts.length === 0) return;
|
|
// postgres-js fans out an array of objects to a multi-row INSERT. Each
|
|
// payload field needs sql.json() so jsonb storage receives a JSON value
|
|
// rather than a quoted string.
|
|
await sql`
|
|
INSERT INTO message_parts ${sql(
|
|
parts.map((p) => ({
|
|
message_id: p.message_id,
|
|
sequence: p.sequence,
|
|
kind: p.kind,
|
|
payload: sql.json(p.payload as never),
|
|
})),
|
|
'message_id',
|
|
'sequence',
|
|
'kind',
|
|
'payload',
|
|
)}
|
|
`;
|
|
}
|
|
|
|
// Derive parts from the canonical messages row for an assistant message.
|
|
// reasoning (when non-empty) becomes a 'reasoning' part at sequence 0 —
|
|
// it precedes user-visible content logically. content (when non-empty)
|
|
// becomes a 'text' part next; each tool_call becomes a 'tool_call' part
|
|
// with payload { id, name, args } where args is the parsed object (we
|
|
// use the in-memory ToolCall shape, not the OpenAI stringified one).
|
|
export function partsFromAssistantMessage(args: {
|
|
content: string;
|
|
tool_calls: ToolCall[] | null;
|
|
// v1.13.1-C: optional reasoning text streamed alongside the answer.
|
|
// Most rows have none — only models with separate reasoning channels
|
|
// (qwen3.6 etc.) populate this.
|
|
reasoning?: string;
|
|
}): Omit<PartInsert, 'message_id'>[] {
|
|
const out: Omit<PartInsert, 'message_id'>[] = [];
|
|
let seq = 0;
|
|
if (args.reasoning && args.reasoning.length > 0) {
|
|
out.push({ sequence: seq, kind: 'reasoning', payload: { text: args.reasoning } });
|
|
seq += 1;
|
|
}
|
|
if (args.content && args.content.length > 0) {
|
|
out.push({ sequence: seq, kind: 'text', payload: { text: args.content } });
|
|
seq += 1;
|
|
}
|
|
for (const tc of args.tool_calls ?? []) {
|
|
out.push({
|
|
sequence: seq,
|
|
kind: 'tool_call',
|
|
payload: { id: tc.id, name: tc.name, args: tc.args },
|
|
});
|
|
seq += 1;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Derive a single tool_result part from a tool message's tool_results JSON.
|
|
// The payload includes the same shape that buildMessagesPayload reads from
|
|
// later: tool_call_id, output, optional error/truncated metadata.
|
|
export function partsFromToolMessage(args: {
|
|
tool_results: ToolResult | null;
|
|
}): Omit<PartInsert, 'message_id'>[] {
|
|
if (!args.tool_results) return [];
|
|
const tr = args.tool_results;
|
|
return [
|
|
{
|
|
sequence: 0,
|
|
kind: 'tool_result',
|
|
payload: {
|
|
tool_call_id: tr.tool_call_id,
|
|
output: tr.output,
|
|
truncated: tr.truncated,
|
|
...(tr.error ? { error: tr.error } : {}),
|
|
},
|
|
},
|
|
];
|
|
}
|