From 2432d029168b86537ba9fa57964813dab1fe1898 Mon Sep 17 00:00:00 2001 From: groovyplanet Date: Thu, 6 Aug 2026 00:59:06 +0900 Subject: [PATCH] fix(interactive): treat missing thinking/text payloads as empty instead of crashing Provider extensions can emit assistant content blocks in the wrong shape (e.g. { type: "thinking", text: ... } instead of { type: "thinking", thinking: ... }, or a bare block with no payload). The smooth streaming reveal read block.thinking.length / block.text.length off undefined and the transcript renderer called .trim() on the missing payload, crashing the whole TUI process with an uncaught TypeError: Cannot read properties of undefined (reading 'length'). Normalize missing payloads to empty strings in countVisibleUnits, buildDisplayMessage, and createAssistantRenderDescriptors, and add a regression test that fails with the exact production TypeError when the fix is reverted. --- .../src/modes/interactive/changes.md | 21 ++++++++++++++++ .../assistant-render-descriptors.ts | 8 +++---- .../interactive/streaming-reveal-content.ts | 18 +++++++------- .../test/streaming-reveal-content.test.ts | 24 ++++++++++++++++++- 4 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/coding-agent/src/modes/interactive/changes.md b/packages/coding-agent/src/modes/interactive/changes.md index c4ed648cc..7a98f8c02 100644 --- a/packages/coding-agent/src/modes/interactive/changes.md +++ b/packages/coding-agent/src/modes/interactive/changes.md @@ -1,5 +1,26 @@ # changes +## Malformed assistant blocks render as empty instead of crashing (2026-08-05) + +### What changed + +- `components/assistant-render-descriptors.ts` and `streaming-reveal-content.ts` now treat a missing `text`/`thinking` + payload on a content block as an empty string instead of reading `.length`/`.trim()` off `undefined`. +- Previously a provider extension emitting a block in the wrong shape (e.g. `{ type: "thinking", text: ... }` instead + of `{ type: "thinking", thinking: ... }`, or a bare block with no payload) crashed the interactive TUI with + `TypeError: Cannot read properties of undefined (reading 'length')` during smooth streaming reveal, and could also + crash transcript rendering for already-stored messages (`content.thinking.trim()`). +- This was changed in core UI because assistant content rendering and streaming reveal are private built-in TUI + behavior; an extension cannot harden the renderer from outside, and the crash otherwise terminates the whole TUI + process (uncaught exception) instead of degrading gracefully. +- `test/streaming-reveal-content.test.ts` covers malformed thinking/text blocks end-to-end; the regression fails + with the exact production `TypeError` when the fix is reverted. + +### Expected merge conflict zones + +- LOW: `streaming-reveal-content.ts` `countVisibleUnits`/`buildDisplayMessage` and + `components/assistant-render-descriptors.ts` payload reads. + ## Server fallback abort uses one TUI notice (2026-08-05) ### What changed diff --git a/packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts b/packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts index 51165535b..aa62f1ea7 100644 --- a/packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts +++ b/packages/coding-agent/src/modes/interactive/components/assistant-render-descriptors.ts @@ -28,9 +28,9 @@ function assertNever(value: never): never { function isVisibleContent(content: AssistantMessage["content"][number], providerNativeVisible: boolean): boolean { switch (content.type) { case "text": - return Boolean(content.text.trim()); + return Boolean((content.text ?? "").trim()); case "thinking": - return Boolean(content.thinking.trim()); + return Boolean((content.thinking ?? "").trim()); case "providerNative": return providerNativeVisible; case "toolCall": @@ -50,7 +50,7 @@ export function createAssistantRenderDescriptors( const content = message.content[i]; switch (content.type) { case "text": { - const text = content.text.trim(); + const text = (content.text ?? "").trim(); if (text) descriptors.push({ kind: "text-md", text }); break; } @@ -74,7 +74,7 @@ export function createAssistantRenderDescriptors( maxEnd = Math.max(maxEnd, endedAt); } } - const thinking = thinkingContent.thinking.trim(); + const thinking = (thinkingContent.thinking ?? "").trim(); if (thinking) thinkingBlocks.push(thinking); } i--; diff --git a/packages/coding-agent/src/modes/interactive/streaming-reveal-content.ts b/packages/coding-agent/src/modes/interactive/streaming-reveal-content.ts index 93e870cab..1e21f15cb 100644 --- a/packages/coding-agent/src/modes/interactive/streaming-reveal-content.ts +++ b/packages/coding-agent/src/modes/interactive/streaming-reveal-content.ts @@ -96,9 +96,9 @@ export function countVisibleUnits(message: AssistantMessage, hideThinking: boole for (let index = 0; index < message.content.length; index++) { const block = message.content[index]; if (block?.type === "text") { - total += countOf(index, block.text); + total += countOf(index, block.text ?? ""); } else if (block?.type === "thinking" && !hideThinking) { - total += countOf(index, block.thinking); + total += countOf(index, block.thinking ?? ""); } } return total; @@ -121,27 +121,29 @@ export function buildDisplayMessage( const block = target.content[index]; if (!block) continue; if (block.type === "text") { - const units = countOf(index, block.text); + const text = block.text ?? ""; + const units = countOf(index, text); content.push( remaining <= 0 - ? block.text.length === 0 + ? text.length === 0 ? block : { ...block, text: "" } : remaining >= units ? block - : { ...block, text: sliceOf(index, block.text, remaining) }, + : { ...block, text: sliceOf(index, text, remaining) }, ); remaining = Math.max(0, remaining - units); } else if (block.type === "thinking" && !hideThinking) { - const units = countOf(index, block.thinking); + const thinking = block.thinking ?? ""; + const units = countOf(index, thinking); content.push( remaining <= 0 - ? block.thinking.length === 0 + ? thinking.length === 0 ? block : { ...block, thinking: "" } : remaining >= units ? block - : { ...block, thinking: sliceOf(index, block.thinking, remaining) }, + : { ...block, thinking: sliceOf(index, thinking, remaining) }, ); remaining = Math.max(0, remaining - units); } else { diff --git a/packages/coding-agent/test/streaming-reveal-content.test.ts b/packages/coding-agent/test/streaming-reveal-content.test.ts index e1af2e7a5..444f22b03 100644 --- a/packages/coding-agent/test/streaming-reveal-content.test.ts +++ b/packages/coding-agent/test/streaming-reveal-content.test.ts @@ -1,6 +1,12 @@ +import type { AssistantMessage } from "@earendil-works/pi-ai"; import { getGraphemeSegmenter } from "@earendil-works/pi-tui"; import { describe, expect, test } from "vitest"; -import { BlockUnitCounter, buildDisplayMessage, visibleUnits } from "../src/modes/interactive/streaming-reveal.ts"; +import { + BlockUnitCounter, + buildDisplayMessage, + countVisibleUnits, + visibleUnits, +} from "../src/modes/interactive/streaming-reveal.ts"; import { makeMessage, textAt, thinkingAt } from "./helpers/streaming-reveal.ts"; function fullSlice(text: string, units: number): string { @@ -83,4 +89,20 @@ describe("streaming reveal content helpers", () => { expect(block.startedAt).toBe(1_000); expect(block.endedAt).toBe(4_200); }); + + test("#given a block missing its payload key #when revealing #then treats it as empty instead of crashing", () => { + const malformedContent = [ + // Real-world corrupt shape: a provider extension emitting `text` instead of `thinking`. + { type: "thinking", text: "reasoning" }, + // Bare block with no payload at all. + { type: "thinking" }, + { type: "text" }, + ] as unknown as AssistantMessage["content"]; + const target = makeMessage(malformedContent); + + expect(() => countVisibleUnits(target, false, (_index, text) => text.length)).not.toThrow(); + expect(() => buildDisplayMessage(target, 0, false)).not.toThrow(); + expect(() => buildDisplayMessage(target, 5, false)).not.toThrow(); + expect(visibleUnits(target, false)).toBe(0); + }); });