fix: tolerate missing text in streamed thinking chunks (#852)

## Summary

This makes streamed text/thinking chunk handling defensive when gateways
emit empty or malformed delta payloads.

- Guard `text_delta.text` and `thinking_delta.thinking` before reading
`.length`
- Skip missing/non-string text or thinking content the same way empty
chunks are already skipped
- Add regression coverage for a missing `thinking` delta before a real
text delta
- Add direct `toAcpNotifications` coverage for missing/non-string text
and thinking chunks

## Why

The adapter already skips empty thinking chunks, but some
Anthropic-compatible gateways can emit signature-only or omitted
thinking deltas where `thinking` is absent rather than an empty string.
In that case the adapter currently throws:

```text
Cannot read properties of undefined (reading 'length')
```

That aborts the ACP session even though the following text chunks are
valid.

This change keeps the existing behavior for valid chunks, while treating
missing/non-string chunk text as empty content.

## Test Plan

- `npm run check`
- `npm test -- --run`
- `npm run build`

Also verified manually through a VibeAround Web Chat flow using Claude
Code ACP with an Anthropic-compatible custom endpoint; the session
completed and returned a normal assistant response.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
This commit is contained in:
Jazzen Chen
2026-07-08 19:23:13 +08:00
committed by GitHub
parent 32b93501d7
commit e944ceddea
2 changed files with 53 additions and 5 deletions

View File

@@ -2029,7 +2029,7 @@ export class ClaudeAcpAgent {
// create a block the consolidated handler's `text.length > 0`
// guard can never consume, stalling the diff cursor and
// re-emitting the next block as a duplicate.
if (chunk && chunk.text.length > 0) {
if (chunk?.text) {
const index = message.event.index;
const last = streamedBlocks[streamedBlocks.length - 1];
if (last && last.index === index && last.type === chunk.type) {
@@ -5091,8 +5091,8 @@ export function toAcpNotifications(
let update: SessionNotification["update"] | null = null;
switch (chunk.type) {
case "text":
case "text_delta":
if (chunk.text.length > 0) {
case "text_delta": {
if (chunk.text) {
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
@@ -5102,6 +5102,7 @@ export function toAcpNotifications(
};
}
break;
}
case "image":
update = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
@@ -5114,10 +5115,10 @@ export function toAcpNotifications(
};
break;
case "thinking":
case "thinking_delta":
case "thinking_delta": {
// Recent models default `thinking.display` to "omitted", which streams
// signature-only thinking blocks whose text is empty.
if (chunk.thinking.length > 0) {
if (chunk.thinking) {
update = {
sessionUpdate: "agent_thought_chunk",
content: {
@@ -5127,6 +5128,7 @@ export function toAcpNotifications(
};
}
break;
}
case "tool_use":
case "server_tool_use":
case "mcp_tool_use": {

View File

@@ -4816,6 +4816,22 @@ describe("assembled assistant text fallback", () => {
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
});
it("dedupes a streamed text block even when a thinking delta omits text", async () => {
const { agent, updates } = createMockAgentWithCapture();
injectSession(agent, [
messageStart("msg-missing-thinking"),
thinkingDelta(undefined as any),
textDelta("real answer"),
assistantMessage("msg-missing-thinking", [{ type: "text", text: "real answer" }]),
result(),
idle,
]);
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
expect(messageChunkTexts(updates)).toEqual(["real answer"]);
});
it("does not re-emit the next turn's text after a turn is cancelled mid-stream", async () => {
// Regression: streamedBlocks is reset inside the consolidated-assistant
// branch, but a cancelled turn `break`s out before reaching it (the
@@ -6795,6 +6811,36 @@ describe("toAcpNotifications thinking chunks", () => {
expect(result).toEqual([]);
});
it("skips thinking chunks without string content", () => {
const result = toAcpNotifications(
[
{ type: "thinking", signature: "abc" },
{ type: "thinking_delta", estimated_tokens: 0 },
{ type: "thinking_delta", thinking: null, estimated_tokens: 0 },
] as any,
"assistant",
"test",
{},
{} as AcpClient,
console,
);
expect(result).toEqual([]);
});
it("skips text deltas without string content", () => {
const result = toAcpNotifications(
[{ type: "text_delta" }, { type: "text_delta", text: null }] as any,
"assistant",
"test",
{},
{} as AcpClient,
console,
);
expect(result).toEqual([]);
});
});
describe("messageIdForGrouping", () => {