Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions packages/coding-agent/src/modes/interactive/changes.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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":
Expand All @@ -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;
}
Expand All @@ -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--;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
24 changes: 23 additions & 1 deletion packages/coding-agent/test/streaming-reveal-content.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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);
});
});