Compare commits
2 Commits
v1.13.3-tr
...
v1.13.5-st
| Author | SHA1 | Date | |
|---|---|---|---|
| ff29b48e3a | |||
| 81d837c04e |
14
CLAUDE.md
14
CLAUDE.md
@@ -46,12 +46,20 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
||||
- **Zod** for request validation and config parsing.
|
||||
|
||||
Key services:
|
||||
- **`services/inference/`** (v1.12.4 split — was a single `inference.ts` file). Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js`. Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner orchestration, plus `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult` exported), `stream-phase.ts` (streamCompletion + executeStreamPhase + SSE parsing), `tool-phase.ts` (executeToolPhase; back-edges into turn.ts for the runAssistantTurn recursion — cycle is safe because dereferenced at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters; two near-clones kept side-by-side until a third sentinel justifies factoring out runWrapUpSummary), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (Qwen-coder XML tool-call fallback), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS` shared between stream-phase and sentinel-summaries). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion (`toolsUsed`, `recentToolCalls`, `assistantMessageId`, `signal`); reset to defaults in `runInference` at the user-message boundary. Cap-hit (`toolsUsed >= budget`) and doom-loop (`detectDoomLoop(recentToolCalls)`) checks both read from this envelope. Add new per-turn state to `TurnArgs` in `turn.ts`, not module-level closures.
|
||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase; value back-edges into turn.ts for the runAssistantTurn recursion — cycle safe because deref at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (v1.13.0 dual-write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts`), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion; reset in `runInference` at user-message boundary. Add new per-turn state to `TurnArgs`, not module-level closures.
|
||||
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Three gotchas the LSP/test suite won't catch:
|
||||
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Only `description` + `inputSchema: jsonSchema(parameters)` — surfacing tool-call parts via `fullStream` and stopping is what we want.
|
||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart` — BooCode's OpenAI-shape history doesn't carry it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` matching the v6 `ToolResultOutput` union. Assistant messages with reasoning emit a `ReasoningPart` first in the content array (v1.13.1-C).
|
||||
- **`experimental_repairToolCall`** (v1.13.3) wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through implementation — logs the bad call and returns it unmodified; `executeToolPhase`'s existing zod-reject error path routes it to the model on the next turn.
|
||||
- **`chat_status` frame shape** (published via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'` (widened from `working|idle|error` in v1.12.1). Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders inline beside `StatusDot` only when streaming or tool_running, fed by 500ms-throttled `'usage'` WS frames (`completion_tokens` + `ctx_used` + `ctx_max`). The `POST /api/chats/:id/discard_stale` endpoint exists to mark a stuck-streaming row as `failed` when the frontend's 60s no-token-activity timer (`ChatPane` content-length watcher) gives up.
|
||||
- **Boot-time stale-streaming sweep** in `apps/server/src/index.ts` after `applySchema()`: any `messages.status='streaming'` older than 5 minutes flips to `'failed'`. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1).
|
||||
- **Periodic 60s sweeper** in `apps/server/src/index.ts` (v1.13.3 + v1.13.5). Same `setInterval` runs `sweepStaleStreaming` (marks `messages.status='streaming'` older than 5 min as `failed`, publishes `chat_status='idle'` so the UI dot drops) and `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `app.addHook('onClose')` clears the timer. No-op when nothing to reap.
|
||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false. v1.13.5 truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs at `BOOCODE_TRUNCATION_DIR` (default `/tmp/boocode-truncations`, 0o700) keyed by an opaque `tr_<12 base32 chars>` id, and the `view_truncated_output(id)` tool retrieves it. 5MB cap (matches `view_file`'s `MAX_FILE_BYTES`), 7-day TTL, reaped by the periodic sweeper. Tmpfs path means container restart loses retrieval — acceptable, the model usually has moved on.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. `COALESCE`s parts-table rows over the legacy JSON columns, so pre-v1.13.0 history still resolves. Writes still target `messages`; the v1.13.0 dual-write into `message_parts` keeps both halves in sync. New payload-assembly code must use the view — calling `messages.tool_calls` directly will miss anything written post-v1.13.1-B if the JSON column ever drifts (and dual-write makes that easy to miss). Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`.
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
turns,
|
||||
select,
|
||||
buildPrompt,
|
||||
buildHeadPayload,
|
||||
type CompactionMessage,
|
||||
} from '../compaction.js';
|
||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||
@@ -31,6 +32,7 @@ function mkMsg(
|
||||
status: 'complete',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
reasoning_parts: null,
|
||||
metadata: null,
|
||||
created_at: new Date(counter * 1000).toISOString(),
|
||||
...overrides,
|
||||
@@ -256,3 +258,56 @@ describe('buildPrompt', () => {
|
||||
expect(out.endsWith('extra-context-line')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildHeadPayload (v1.13.6) -----------------------------------------------
|
||||
|
||||
describe('buildHeadPayload reasoning render', () => {
|
||||
it('emits reasoning as a <reasoning> tag prefixed onto the assistant content', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('user', 'show me the file'),
|
||||
mkMsg('assistant', 'reading it now', {
|
||||
reasoning_parts: [{ text: 'user wants src/index.ts; I should view it' }],
|
||||
}),
|
||||
]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[1]!.role).toBe('assistant');
|
||||
expect(out[1]!.content).toBe(
|
||||
'<reasoning>user wants src/index.ts; I should view it</reasoning>\n\nreading it now',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a standalone <reasoning> tag when reasoning is present but content is empty (tool-call-only turn)', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', '', {
|
||||
reasoning_parts: [{ text: 'jumping straight to grep' }],
|
||||
tool_calls: [{ id: 'c1', name: 'grep', args: { pattern: 'foo' } }],
|
||||
}),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.content).toBe('<reasoning>jumping straight to grep</reasoning>');
|
||||
expect(out[0]!.tool_calls).toHaveLength(1);
|
||||
expect(out[0]!.tool_calls![0]!.function.name).toBe('grep');
|
||||
});
|
||||
|
||||
it('joins multiple reasoning parts without separators (matches the streaming concat)', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', 'final answer', {
|
||||
reasoning_parts: [{ text: 'first thought ' }, { text: 'second thought' }],
|
||||
}),
|
||||
]);
|
||||
expect(out[0]!.content).toBe(
|
||||
'<reasoning>first thought second thought</reasoning>\n\nfinal answer',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the reasoning tag entirely when reasoning_parts is null or empty', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', 'plain answer', { reasoning_parts: null }),
|
||||
mkMsg('assistant', 'other answer', { reasoning_parts: [] }),
|
||||
]);
|
||||
expect(out[0]!.content).toBe('plain answer');
|
||||
expect(out[1]!.content).toBe('other answer');
|
||||
expect(out[0]!.content).not.toContain('<reasoning>');
|
||||
expect(out[1]!.content).not.toContain('<reasoning>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -39,6 +39,11 @@ export interface CompactionMessage {
|
||||
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
tool_calls: Array<{ id: string; name: string; args: Record<string, unknown> }> | null;
|
||||
tool_results: { tool_call_id: string; output: unknown; truncated: boolean; error?: string } | null;
|
||||
// v1.13.6: reasoning_parts captured by v1.13.1-C and read back through
|
||||
// messages_with_parts. Embedded into the head-assembly payload as prose so
|
||||
// the summarizer LLM sees what the model was reasoning through when it
|
||||
// chose its tool calls.
|
||||
reasoning_parts: Array<{ text: string }> | null;
|
||||
metadata: { kind?: string } | null;
|
||||
created_at: string;
|
||||
}
|
||||
@@ -197,7 +202,8 @@ export function buildPrompt(
|
||||
// would silently drop pre-legacy-compact history before the LLM sees it.
|
||||
// Compaction wants to send the entire head, full stop.) ===
|
||||
|
||||
interface OpenAiMessage {
|
||||
// v1.13.6: exported for unit-test access (reasoning render coverage).
|
||||
export interface OpenAiMessage {
|
||||
role: 'system' | 'user' | 'assistant' | 'tool';
|
||||
content: string | null;
|
||||
tool_calls?: Array<{
|
||||
@@ -212,7 +218,8 @@ function isCapHitSentinel(m: CompactionMessage): boolean {
|
||||
return m.role === 'system' && m.metadata != null && m.metadata.kind === 'cap_hit';
|
||||
}
|
||||
|
||||
function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||
// v1.13.6: exported for unit-test access (reasoning render coverage).
|
||||
export function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||
const out: OpenAiMessage[] = [];
|
||||
for (const m of head) {
|
||||
if (isCapHitSentinel(m)) continue;
|
||||
@@ -243,9 +250,22 @@ function buildHeadPayload(head: CompactionMessage[]): OpenAiMessage[] {
|
||||
continue;
|
||||
}
|
||||
if (m.role === 'assistant') {
|
||||
// v1.13.6: embed reasoning text as prose prefixed onto the assistant
|
||||
// content. OpenAI wire shape doesn't carry reasoning as a structured
|
||||
// field, but the summarizer is reading text — a tagged prose block
|
||||
// gives it the same signal. We mirror the AI SDK ReasoningPart shape
|
||||
// by using a <reasoning>...</reasoning> wrapper so the summarizer can
|
||||
// distinguish reasoning from user-visible answer.
|
||||
let body = m.content && m.content.length > 0 ? m.content : '';
|
||||
if (m.reasoning_parts && m.reasoning_parts.length > 0) {
|
||||
const reasoning = m.reasoning_parts.map((r) => r.text).join('');
|
||||
body = body.length > 0
|
||||
? `<reasoning>${reasoning}</reasoning>\n\n${body}`
|
||||
: `<reasoning>${reasoning}</reasoning>`;
|
||||
}
|
||||
const msg: OpenAiMessage = {
|
||||
role: 'assistant',
|
||||
content: m.content && m.content.length > 0 ? m.content : null,
|
||||
content: body.length > 0 ? body : null,
|
||||
};
|
||||
if (m.tool_calls && m.tool_calls.length > 0) {
|
||||
msg.tool_calls = m.tool_calls.map((tc) => ({
|
||||
@@ -344,8 +364,11 @@ export async function process(input: ProcessInput): Promise<void> {
|
||||
// turns() boundary logic sees the same sequence the LLM will.
|
||||
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view so
|
||||
// the compaction payload matches what the LLM saw on the original turn.
|
||||
// v1.13.6: also pulls reasoning_parts (added in v1.13.1-C) so summaries
|
||||
// capture what the model was working through before each tool call.
|
||||
const messages = await sql<CompactionMessage[]>`
|
||||
SELECT id, role, content, kind, summary, status, tool_calls, tool_results, metadata, created_at
|
||||
SELECT id, role, content, kind, summary, status, tool_calls, tool_results,
|
||||
reasoning_parts, metadata, created_at
|
||||
FROM messages_with_parts
|
||||
WHERE chat_id = ${chatId} AND compacted_at IS NULL
|
||||
ORDER BY created_at ASC, id ASC
|
||||
|
||||
@@ -5,10 +5,15 @@ import { READ_ONLY_TOOL_NAMES } from '../tools.js';
|
||||
// - Agent with explicit max_tool_calls: that value.
|
||||
// - Agent with read-only-only tools: BUDGET_READ_ONLY (30).
|
||||
// - Agent with any non-read-only tool: BUDGET_NON_READ_ONLY (10).
|
||||
// - No agent (raw chat): BUDGET_NO_AGENT (15).
|
||||
// - No agent (raw chat): BUDGET_NO_AGENT (30).
|
||||
// v1.13.7: bumped BUDGET_NO_AGENT 15→30 to match BUDGET_READ_ONLY. Every tool
|
||||
// in ALL_TOOLS today is read-only (see services/tools.ts comment at
|
||||
// READ_ONLY_TOOL_NAMES); the cautious 15-cap was a forward-looking guard for
|
||||
// write tools that haven't landed yet. No-agent mode gets the same toolset as
|
||||
// an all-read-only agent at runtime, so they should share the same budget.
|
||||
export const BUDGET_READ_ONLY = 30;
|
||||
export const BUDGET_NON_READ_ONLY = 10;
|
||||
export const BUDGET_NO_AGENT = 15;
|
||||
export const BUDGET_NO_AGENT = 30;
|
||||
|
||||
const READ_ONLY_SET: ReadonlySet<string> = new Set(READ_ONLY_TOOL_NAMES);
|
||||
|
||||
|
||||
@@ -63,6 +63,25 @@ export async function buildMessagesPayload(
|
||||
if (isAnySentinel(m)) continue;
|
||||
if (m.role === 'assistant' && m.status === 'streaming') continue;
|
||||
if (m.role === 'assistant' && m.status === 'cancelled') continue;
|
||||
// v1.13.7: skip failed assistant turns. A failed row carries no usable
|
||||
// content for the model, and leaving it in the payload alongside any
|
||||
// following assistant message produces "Cannot have 2 or more assistant
|
||||
// messages at the end of the list" from the OpenAI-compatible upstream.
|
||||
if (m.role === 'assistant' && m.status === 'failed') continue;
|
||||
// v1.13.7: skip "empty" completed assistants — clen=0 + no tool_calls.
|
||||
// These can land when an upstream stream returns finishReason='stop' with
|
||||
// no text/tool output (network blip, rate limit recovery, model quirk).
|
||||
// Same risk as the failed-status case: a trailing empty assistant plus
|
||||
// the next attempt's assistant placeholder = two trailing assistants and
|
||||
// the API rejects the whole payload.
|
||||
if (
|
||||
m.role === 'assistant' &&
|
||||
m.status === 'complete' &&
|
||||
(m.content == null || m.content.trim().length === 0) &&
|
||||
(m.tool_calls == null || m.tool_calls.length === 0)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (m.role === 'tool') {
|
||||
const tr = m.tool_results;
|
||||
if (!tr) continue;
|
||||
|
||||
@@ -15,6 +15,14 @@ function getProvider(baseURL: string): ReturnType<typeof createOpenAICompatible>
|
||||
provider = createOpenAICompatible({
|
||||
name: 'llama-swap',
|
||||
baseURL: baseURL.endsWith('/v1') ? baseURL : `${baseURL}/v1`,
|
||||
// v1.13.7: @ai-sdk/openai-compatible defaults includeUsage=false, which
|
||||
// omits `stream_options.include_usage` from the request body. Without
|
||||
// it, llama.cpp / llama-swap never emits the trailing usage block, so
|
||||
// `result.usage` resolves with inputTokens=outputTokens=undefined and
|
||||
// tokens_used / ctx_used land as NULL in every messages row. Setting
|
||||
// true here re-enables the per-stream usage payload across all models
|
||||
// served via the llama-swap provider.
|
||||
includeUsage: true,
|
||||
});
|
||||
cache.set(baseURL, provider);
|
||||
}
|
||||
|
||||
@@ -651,7 +651,9 @@ export function MessageBubble({ message, sessionChats, capHitInfo }: Props) {
|
||||
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
const hasContent = message.content.length > 0;
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
// v1.8.2: if metadata stamps an error reason, surface it inline under the
|
||||
// generic "message failed" line. Keeps the user's eye where it already is
|
||||
// rather than introducing a separate banner.
|
||||
|
||||
@@ -45,7 +45,12 @@ function flatten(messages: Message[]): RenderItem[] {
|
||||
continue;
|
||||
}
|
||||
const hasToolCalls = m.tool_calls != null && m.tool_calls.length > 0;
|
||||
const hasText = m.content.length > 0;
|
||||
// v1.13.7: trim before checking. AI SDK v6 streaming occasionally emits a
|
||||
// leading "\n" text-delta on tool-call-only turns, which used to flow into
|
||||
// messages.content with length=1 and render an empty bubble + ActionRow
|
||||
// between each tool call. Whitespace-only content has no visible payload,
|
||||
// so treat it as no-content.
|
||||
const hasText = m.content.trim().length > 0;
|
||||
if (m.role === 'assistant' && hasToolCalls) {
|
||||
if (hasText || m.status === 'streaming') {
|
||||
items.push({ kind: 'message', message: m });
|
||||
|
||||
Reference in New Issue
Block a user