From 70f264b19976f659794793402673a45bc2cc7a47 Mon Sep 17 00:00:00 2001 From: DIodide Date: Sat, 22 Aug 2026 14:19:18 -0400 Subject: [PATCH] Stitch recovery-split text parts back together; add stream-stall watchdog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A stream interruption mid-turn makes Think persist the partial and append the continuation as a NEW text part — often mid-sentence. The chat renderer treated each text part as a standalone markdown document, tearing sentences and lists apart at the seam ("Get a major" / "'s full requirement tree…"). Consecutive text parts are now concatenated before rendering. Also sets chatStreamStallTimeoutMs = 120s so a model stream that parks without erroring gets aborted into Think's bounded recovery path instead of leaving the client spinning — the likely source of the "spotty" feel. Claude-Session: https://claude.ai/code/session_01MKLJUWk6biNAKXupHTTWn5 --- app/src/client/pages/ChatPage.tsx | 21 ++++++++++++++++++++- app/src/server/pi.ts | 6 ++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/client/pages/ChatPage.tsx b/app/src/client/pages/ChatPage.tsx index edf9a37..b4dcc40 100644 --- a/app/src/client/pages/ChatPage.tsx +++ b/app/src/client/pages/ChatPage.tsx @@ -18,6 +18,25 @@ const SUGGESTIONS = [ "Does anything in my schedule conflict?", ]; +/** + * A stream interruption + recovery can split one utterance across several + * consecutive text parts (often mid-sentence). Rendering each part as its + * own markdown block tears sentences and lists apart, so stitch runs of + * text parts back together before rendering. + */ +function coalesceParts(parts: UIMessage["parts"]): UIMessage["parts"] { + const out: UIMessage["parts"] = []; + for (const part of parts) { + const last = out[out.length - 1]; + if (part.type === "text" && last?.type === "text") { + out[out.length - 1] = { ...last, text: last.text + part.text }; + } else { + out.push(part); + } + } + return out; +} + function messageText(m: UIMessage): string { return m.parts .map((p) => (p.type === "text" ? p.text : "")) @@ -328,7 +347,7 @@ function Turn({ return (
- {message.parts.map((part, i) => { + {coalesceParts(message.parts).map((part, i) => { if (part.type === "text") { return part.text.trim() ? : null; } diff --git a/app/src/server/pi.ts b/app/src/server/pi.ts index 9d2e8dd..ccd2388 100644 --- a/app/src/server/pi.ts +++ b/app/src/server/pi.ts @@ -32,6 +32,12 @@ export class Pi extends Think { workspaceBash = false; waitForMcpConnections = { timeout: 15_000 }; maxSteps = 12; + /** + * Abort-and-recover a model stream that parks without erroring — the + * cause of "spinning forever" turns. Set well above the slowest + * time-to-first-token plus the slowest MCP tool call. + */ + chatStreamStallTimeoutMs = 120_000; getDefaultTimezone() { return "America/New_York";