After a codecontext overview-class tool call lands (get_codebase_overview, get_framework_analysis, get_semantic_neighborhoods), the pipeline runs a second inference pass that replaces the recursive runAssistantTurn. The synth pass auto-fetches the top-N source files referenced in the codecontext output plus project docs (BOOCHAT.md, AGENTS.md, *roadmap*.md, CONTEXT.md), applies a 32k-token budget with explicit drop-priority, and streams a structured response that grounds the model in real load-bearing code rather than relying on the codecontext summary alone. Smoke #1 (default) and #2 (Architect) both cite the correct inference/turn.ts + tool-phase.ts + stream-phase.ts files; smoke #6 (fault injection) verifies the fall-through path marks the synth message status='failed' and yields cleanly to the recursive turn. ## Truncation-aware extraction codecontext's wrapper inline-truncates results at 32k chars. Without the expansion step, the top-N file selection only saw the alphabetical head of the codebase (apps/booterm/dist/*) and auto-fetched the wrong sources. The pipeline now calls in-process readTruncation(outputPath) before extracting referenced files, so top-N selection sees the full 80k+ char output. The 32k truncated head still ships to the synth model — the expansion is reference-extraction-only, preserving the token-budget contract. Graceful degradation on readTruncation null/throw: log warn, fall back to the truncated head. ## Schema deviation from dispatch The dispatch claimed no schema migration was needed for the new 'synthesis' part kind. Reality: message_parts.kind has an explicit CHECK constraint (schema.sql:54) that would reject the new value. Added a DROP CONSTRAINT IF EXISTS + DO $$ pg_constraint idempotency-guarded re-add matching the CLAUDE.md migration pattern. The inline CREATE TABLE constraint also updated so fresh installs land with the extended enum. ## User-abort marks synth-message failed Deviation from review-time spec ("user-abort path does NOT mark the message failed"). The outer abort handler in error-handler.ts operates on the parent turn's assistantMessageId, not the new synth row that runSynthesisPass created. Without explicit marking, the synth row would sit in status='streaming' until the 5-min stale-streaming sweeper (v1.13.1-cleanup-bundle), tripping the frontend's 60s no-token-activity banner in the meantime — exactly the UX bug class the v1.13.1 sweeper was added to handle. Marking failed on every catch path (including user-abort) closes the gap. Cost: one extra DB write + one publish on the rare user-abort-during-synth path. ## Race-safe synth-tool capture tool-phase.ts uses synthEntries: Array<{tc, output, error?}> with per-callback push under Promise.all. find() picks the first non-error entry by call-order (toolCalls array index). Multiple synth-tools in one batch are uncommon but handled deterministically. ## Roadmap rebase Updated boocode_roadmap.md retrospective section + cleanup-order tracker + schema-changes summary to use the new vMAJOR.MINOR.PATCH-slug tag names per the 2026-05-22 retag (CHANGELOG.md is the canonical record). v1.13.15 listed as "this batch, tag pending"; a one-line follow-up commit will remove that qualifier after the tag lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
106 lines
3.5 KiB
TypeScript
106 lines
3.5 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.
|
|
|
|
// v1.13.13: 'synthesis' added. Schema CHECK constraint is updated in lockstep
|
|
// (schema.sql adds 'synthesis' to message_parts_kind_chk on startup). The
|
|
// dispatch's claim that no schema migration was needed assumed kind was a
|
|
// bare text column — it isn't; the constraint enumerates allowed values.
|
|
export type PartKind =
|
|
| 'text'
|
|
| 'tool_call'
|
|
| 'tool_result'
|
|
| 'reasoning'
|
|
| 'step_start'
|
|
| 'synthesis';
|
|
|
|
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 } : {}),
|
|
},
|
|
},
|
|
];
|
|
}
|