v1.13.1-C: port ask_user_input correlation to parts + wire reasoning_parts end-to-end

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>
This commit is contained in:
2026-05-22 06:34:10 +00:00
parent 13c3aa5b4e
commit ac1a71f583
10 changed files with 169 additions and 63 deletions

View File

@@ -52,6 +52,36 @@ describe('partsFromAssistantMessage', () => {
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', () => {