mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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": {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user