From 67e0dca9aefb148a0359c83fe14659d51132bd83 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:29:15 -0700 Subject: [PATCH 1/4] fix(web): keep session result expanded when its text never rendered The result event always carries a copy of the final reply text, normally redundant with the streamed assistant message. When the turn's content is lost (e.g. the CLI stops emitting stream events after API retries), that copy is the only surviving record of the reply, yet it rendered collapsed as a bare checkmark divider, indistinguishable from "no response". Compare the result text against the last main-turn assistant message when the result arrives; if it isn't already on screen, flag it and start the result block expanded, like error results already do. --- web/src/components/MessageList.tsx | 4 +- web/src/sessionReducer.test.ts | 65 ++++++++++++++++++++++++++++++ web/src/sessionReducer.ts | 33 +++++++++++++++ web/src/types.ts | 4 ++ 4 files changed, 105 insertions(+), 1 deletion(-) diff --git a/web/src/components/MessageList.tsx b/web/src/components/MessageList.tsx index b51f389..7d8e955 100644 --- a/web/src/components/MessageList.tsx +++ b/web/src/components/MessageList.tsx @@ -45,7 +45,9 @@ function ResultMessageView({ message }: { message: DisplayMessage }) { const d = message.resultData!; const durationSec = d.durationMs ? Math.floor(d.durationMs / 1000) : 0; const apiSec = d.durationApiMs ? Math.floor(d.durationApiMs / 1000) : 0; - const [expanded, setExpanded] = useState(d.isError); + // Errors and otherwise-unseen reply text must be visible without a click; + // collapsing them to the divider would hide real content behind a checkmark. + const [expanded, setExpanded] = useState(d.isError || !!d.resultUnseen); if (!expanded) { return ( diff --git a/web/src/sessionReducer.test.ts b/web/src/sessionReducer.test.ts index 9a1b00f..29b0a73 100644 --- a/web/src/sessionReducer.test.ts +++ b/web/src/sessionReducer.test.ts @@ -574,3 +574,68 @@ describe("cydo/task_spawned reducer", () => { expect(s.pendingCydoTaskItemIds).toEqual([]); }); }); + +describe("result text visibility", () => { + const resultEvent = (result?: string) => + asEvent({ + type: "turn/result", + subtype: "success", + is_error: false, + num_turns: 1, + duration_ms: 1, + total_cost_usd: 0, + usage: { input_tokens: 1, output_tokens: 1 }, + result, + }); + + function streamAssistantText(s: TaskState, text: string): TaskState { + s = reduceMessage( + s, + asEvent({ + type: "item/started", + item_type: "text", + item_id: "cc-block-0", + }), + ); + s = reduceMessage( + s, + asEvent({ + type: "item/delta", + item_id: "cc-block-0", + delta_type: "text_delta", + content: text, + }), + ); + s = reduceMessage( + s, + asEvent({ type: "item/completed", item_id: "cc-block-0" }), + ); + return reduceMessage(s, asEvent({ type: "turn/stop" })); + } + + it("marks result as redundant when its text was streamed", () => { + let s: TaskState = makeState(); + s = streamAssistantText(s, "Hello there friend."); + s = reduceMessage(s, resultEvent("Hello there friend.")); + expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(false); + }); + + it("marks result as unseen when no assistant message exists", () => { + let s: TaskState = makeState(); + s = reduceMessage(s, resultEvent("Reply that never streamed.")); + expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(true); + }); + + it("marks result as unseen when the streamed text differs", () => { + let s: TaskState = makeState(); + s = streamAssistantText(s, "Some earlier partial output."); + s = reduceMessage(s, resultEvent("Reply that never streamed.")); + expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(true); + }); + + it("does not mark resultless events as unseen", () => { + let s: TaskState = makeState(); + s = reduceMessage(s, resultEvent(undefined)); + expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(false); + }); +}); diff --git a/web/src/sessionReducer.ts b/web/src/sessionReducer.ts index 5d5ed68..b6836e9 100644 --- a/web/src/sessionReducer.ts +++ b/web/src/sessionReducer.ts @@ -702,6 +702,7 @@ export function reduceResultMessage( permissionDenials: msg.permission_denials, stopReason: msg.stop_reason, errors: msg.errors, + resultUnseen: isResultTextUnseen(messages, blocks, msg.result), }, }, ], @@ -709,6 +710,38 @@ export function reduceResultMessage( return msg.is_error ? cancelPendingFileEdits(nextState) : nextState; } +/** The result event always carries a copy of the final reply text. Normally + * it's redundant with the streamed assistant message, but when the turn's + * content never rendered (e.g. stream events lost after API retries) that + * copy is the only survivor — detect this so the result block isn't hidden + * behind the collapsed divider. */ +function isResultTextUnseen( + messages: DisplayMessage[], + blocks: Map, + resultText: string | undefined, +): boolean { + if (!resultText) return false; + const needle = resultText.trim(); + if (needle.length === 0) return false; + + // The result text is the final main-turn assistant message's text, so only + // the most recent non-sub-agent assistant message needs checking. + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.type !== "assistant" || m.parentToolUseId) continue; + const texts: string[] = []; + for (const blockId of m.blockIds ?? []) { + const b = blocks.get(blockId); + if (b && b.type === "text") texts.push(b.text); + } + for (const c of m.content) { + if (c.type === "text" && c.text !== undefined) texts.push(c.text); + } + return !texts.join("\n").includes(needle); + } + return true; +} + /** Insert a message before any in-progress streaming assistant message. * User messages should always precede the assistant's response, but the * protocol may deliver the user echo after streaming has already started. */ diff --git a/web/src/types.ts b/web/src/types.ts index a528b7d..41e27be 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -72,6 +72,10 @@ export interface DisplayMessage { permissionDenials?: unknown[]; stopReason?: string | null; errors?: string[]; + /** True when the result text never appeared as an assistant message + * (e.g. stream events lost after API retries) — the result block is + * then the only copy of the reply and must not start collapsed. */ + resultUnseen?: boolean; }; // Rate limit fields rateLimitInfo?: { From 549ce40b53774d5b94f672bf613549ea1b9a2476 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Thu, 11 Jun 2026 10:26:45 -0700 Subject: [PATCH 2/4] fix(claude): promote assistant content when stream events are missing After an API retry, Claude CLI stops emitting stream_event partials for the rest of the process run. Live translation promoted content to visible items only from content_block_* stream events, treating complete assistant events as metadata-only turn/delta, so entire turns rendered as nothing; the final reply text survived only inside the collapsed session-complete result block. When an assistant event arrives and no stream blocks are active, promote its content blocks directly (item/started + item/completed), the same way the sub-agent path already does. Generated item ids use a session-wide counter since per-block assistant events carry no stream index. Also reset stream tracking when an api_retry system event passes through, so a mid-stream abort can't leave stale state that suppresses the fallback. --- source/cydo/agent/drivers/claude.d | 82 ++++++++++++++++++++++++++---- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/source/cydo/agent/drivers/claude.d b/source/cydo/agent/drivers/claude.d index c328cfb..86c8042 100644 --- a/source/cydo/agent/drivers/claude.d +++ b/source/cydo/agent/drivers/claude.d @@ -704,6 +704,7 @@ class ClaudeCodeSession : AgentSession private string[] activeItemIds_; // index → item_id for current turn private string[] activeItemTypes_; // index → "text", "thinking", "tool_use" private JSONFragment[string] blockExtras_; // item_id → extras from assistant event + private size_t promotedBlockSeq_; // unique ids for blocks promoted from assistant events private AbsTime lineReceiptTs_; // receipt time captured at start of each live line private string executablePath_; private string agentName_; @@ -908,6 +909,16 @@ class ClaudeCodeSession : AgentSession normalizeUserLive(rawLine); return; default: + // An api retry means the in-flight attempt died; any blocks it + // opened will never complete, and the retried response may arrive + // with no stream events at all (the CLI stops emitting partials + // for the rest of the run), so reset stream tracking to let the + // assistant-event promotion fallback take over. + if (probe.type == "system" && probe.subtype == "api_retry") + { + activeItemIds_ = null; + activeItemTypes_ = null; + } // Stateless translation for system, result, summary, control, etc. auto t = translateClaudeEvent(rawLine, agentName_); if (t.translated !is null) @@ -1221,20 +1232,69 @@ class ClaudeCodeSession : AgentSession catch (Exception) {} } - // Cache per-block extras so content_block_stop can attach them. - foreach (idx, ref b; raw.message.content) + if (activeItemIds_.length == 0 && raw.message.content.length > 0) { - auto frag = extrasToFragment(b._extras); - if (frag.json !is null && frag.json.length > 0) + // No stream events delivered this message's content (after an api + // retry the CLI stops emitting partials for the rest of the run, so + // content_block_start never fires). Without a fallback the whole + // turn would render as nothing. Promote the blocks directly, like + // the sub-agent path above. + import cydo.protocol : ItemStartedEvent, ItemCompletedEvent, + decomposeToolName; + + foreach (ref b; raw.message.content) { - string itemId; - if (idx < activeItemIds_.length && activeItemIds_[idx].length > 0) - itemId = activeItemIds_[idx]; - else if (b.type == "tool_use" && b.id.length > 0) - itemId = b.id; + // tool_use ids are globally unique already; generated ids use a + // session-wide counter because per-block assistant events carry + // no stream index ("cc-block-" would collide across blocks) + auto itemId = b.type == "tool_use" && b.id.length > 0 + ? b.id : "cc-promoted-" ~ to!string(promotedBlockSeq_++); + + ItemStartedEvent startEv; + startEv.item_id = itemId; + startEv.item_type = b.type; + if (b.type == "tool_use") + { + decomposeToolName(b.name, startEv.name, startEv.tool_server, startEv.tool_source); + startEv.input = b.input; + } else - itemId = "cc-block-" ~ to!string(idx); - blockExtras_[itemId] = frag; + { + auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; + startEv.text = text; + } + emitEvent(TranslatedEvent(toJson(startEv), rawLine)); + + ItemCompletedEvent compEv; + compEv.item_id = itemId; + if (b.type == "tool_use") + compEv.input = b.input; + else + { + auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; + compEv.text = text; + } + compEv.extras = extrasToFragment(b._extras); + emitEvent(TranslatedEvent(toJson(compEv), rawLine)); + } + } + else + { + // Cache per-block extras so content_block_stop can attach them. + foreach (idx, ref b; raw.message.content) + { + auto frag = extrasToFragment(b._extras); + if (frag.json !is null && frag.json.length > 0) + { + string itemId; + if (idx < activeItemIds_.length && activeItemIds_[idx].length > 0) + itemId = activeItemIds_[idx]; + else if (b.type == "tool_use" && b.id.length > 0) + itemId = b.id; + else + itemId = "cc-block-" ~ to!string(idx); + blockExtras_[itemId] = frag; + } } } From a0901252fe0ad44f646d6e2b861e865ceaa38455 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Wed, 24 Jun 2026 21:06:20 -0700 Subject: [PATCH 3/4] test(claude): unit-test assistant content promotion translateAssistantLive lives on the process-spawning ClaudeCodeSession, so the promotion fallback cannot be driven from a unit test directly. Extract the per-block promotion into a pure static promoteContentBlocks (hoisting the ClaudeBlock parse struct to class scope) and unit-test that a stream-less text block becomes item/started + item/completed and a tool_use block keeps its own id. No behavior change. --- source/cydo/agent/drivers/claude.d | 142 +++++++++++++++++++---------- 1 file changed, 93 insertions(+), 49 deletions(-) diff --git a/source/cydo/agent/drivers/claude.d b/source/cydo/agent/drivers/claude.d index 86c8042..e8fed8a 100644 --- a/source/cydo/agent/drivers/claude.d +++ b/source/cydo/agent/drivers/claude.d @@ -1082,6 +1082,97 @@ class ClaudeCodeSession : AgentSession } } + @JSONPartial static struct ClaudeBlock + { + string type; + @JSONOptional string id; + @JSONOptional string name; + @JSONOptional JSONFragment input; + @JSONOptional string text; + @JSONOptional string thinking; + @JSONOptional string signature; + JSONExtras _extras; + } + + /// Promote a complete assistant message's content blocks straight to + /// item/started + item/completed events. This is the live fallback for when + /// no stream events arrived (after an api_retry the CLI stops emitting + /// partials, so content_block_start never fires and the turn would otherwise + /// render as nothing). blockSeq hands out ids for non-tool blocks. + private static TranslatedEvent[] promoteContentBlocks( + ClaudeBlock[] blocks, string rawLine, ref size_t blockSeq) + { + import cydo.protocol : ItemStartedEvent, ItemCompletedEvent, decomposeToolName; + + TranslatedEvent[] events; + foreach (ref b; blocks) + { + // tool_use ids are globally unique already; generated ids use a + // session-wide counter because per-block assistant events carry no + // stream index ("cc-block-" would collide across blocks) + auto itemId = b.type == "tool_use" && b.id.length > 0 + ? b.id : "cc-promoted-" ~ to!string(blockSeq++); + + ItemStartedEvent startEv; + startEv.item_id = itemId; + startEv.item_type = b.type; + if (b.type == "tool_use") + { + decomposeToolName(b.name, startEv.name, startEv.tool_server, startEv.tool_source); + startEv.input = b.input; + } + else + { + auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; + startEv.text = text; + } + events ~= TranslatedEvent(toJson(startEv), rawLine); + + ItemCompletedEvent compEv; + compEv.item_id = itemId; + if (b.type == "tool_use") + compEv.input = b.input; + else + { + auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; + compEv.text = text; + } + compEv.extras = extrasToFragment(b._extras); + events ~= TranslatedEvent(toJson(compEv), rawLine); + } + return events; + } + + unittest + { + import std.algorithm : canFind; + + // a complete assistant text block with no stream events must promote to + // item/started + item/completed carrying its text; before the fix the + // turn rendered as nothing + ClaudeBlock textBlock; + textBlock.type = "text"; + textBlock.text = "promoted hello"; + + size_t seq; + auto evs = promoteContentBlocks([textBlock], "raw", seq); + assert(evs.canFind!(e => e.translated.canFind(`"item/started"`)), + "text content must promote to item/started"); + assert(evs.canFind!(e => e.translated.canFind(`"item/completed"`)), + "text content must promote to item/completed"); + assert(evs.canFind!(e => e.translated.canFind("promoted hello")), + "the promoted item must carry the assistant text"); + + // a tool_use block keeps its own globally-unique id, not a counter id + ClaudeBlock toolBlock; + toolBlock.type = "tool_use"; + toolBlock.id = "toolu_42"; + toolBlock.name = "Bash"; + assert(promoteContentBlocks([toolBlock], "raw", seq) + .canFind!(e => e.translated.canFind("toolu_42")), + "tool_use block must keep its own id"); + } + /// Translate an assistant NDJSON event to a turn/delta metadata event. /// Content promotion is handled by content_block_stop → item/completed. /// Exception: sub-agent messages (parent_tool_use_id set) arrive as complete @@ -1090,17 +1181,6 @@ class ClaudeCodeSession : AgentSession { import cydo.protocol : TurnDeltaEvent, UsageInfo; - @JSONPartial static struct ClaudeBlock - { - string type; - @JSONOptional string id; - @JSONOptional string name; - @JSONOptional JSONFragment input; - @JSONOptional string text; - @JSONOptional string thinking; - @JSONOptional string signature; - JSONExtras _extras; - } @JSONPartial static struct ClaudeMessage { @JSONOptional string model; @@ -1239,44 +1319,8 @@ class ClaudeCodeSession : AgentSession // content_block_start never fires). Without a fallback the whole // turn would render as nothing. Promote the blocks directly, like // the sub-agent path above. - import cydo.protocol : ItemStartedEvent, ItemCompletedEvent, - decomposeToolName; - - foreach (ref b; raw.message.content) - { - // tool_use ids are globally unique already; generated ids use a - // session-wide counter because per-block assistant events carry - // no stream index ("cc-block-" would collide across blocks) - auto itemId = b.type == "tool_use" && b.id.length > 0 - ? b.id : "cc-promoted-" ~ to!string(promotedBlockSeq_++); - - ItemStartedEvent startEv; - startEv.item_id = itemId; - startEv.item_type = b.type; - if (b.type == "tool_use") - { - decomposeToolName(b.name, startEv.name, startEv.tool_server, startEv.tool_source); - startEv.input = b.input; - } - else - { - auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; - startEv.text = text; - } - emitEvent(TranslatedEvent(toJson(startEv), rawLine)); - - ItemCompletedEvent compEv; - compEv.item_id = itemId; - if (b.type == "tool_use") - compEv.input = b.input; - else - { - auto text = b.type == "thinking" && b.thinking.length > 0 ? b.thinking : b.text; - compEv.text = text; - } - compEv.extras = extrasToFragment(b._extras); - emitEvent(TranslatedEvent(toJson(compEv), rawLine)); - } + foreach (ev; promoteContentBlocks(raw.message.content, rawLine, promotedBlockSeq_)) + emitEvent(ev); } else { From e68604735862c3fd344f7ac916db9b2b76d37262 Mon Sep 17 00:00:00 2001 From: Antisophy <293439221+Antisophy@users.noreply.github.com> Date: Wed, 1 Jul 2026 11:42:28 -0700 Subject: [PATCH 4/4] test(web): cover the partial-stream result-visibility case The existing result-visibility tests cover the total-blackout shapes (nothing streamed, or streamed text differs). Add the partial case: a thinking block renders while the following text block never streams, so the final reply text is not among the rendered text blocks and the result stays expanded. This is the condition observed in practice, distinct from the total blackout. --- web/src/sessionReducer.test.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/web/src/sessionReducer.test.ts b/web/src/sessionReducer.test.ts index 29b0a73..5355759 100644 --- a/web/src/sessionReducer.test.ts +++ b/web/src/sessionReducer.test.ts @@ -638,4 +638,19 @@ describe("result text visibility", () => { s = reduceMessage(s, resultEvent(undefined)); expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(false); }); + + it("marks result as unseen when only a thinking block rendered", () => { + // Partial stream: the thinking block streamed and rendered, but the text + // block's stream events never arrived, so the final reply survived only in + // the result. A rendered thinking block is not a text block, so it must not + // count the result text as seen. + let s: TaskState = makeState(); + s = reduceMessage( + s, + asEvent({ type: "item/started", item_id: "think-1", item_type: "thinking" }), + ); + s = reduceMessage(s, asEvent({ type: "turn/stop" })); + s = reduceMessage(s, resultEvent("Both done, committed.")); + expect(s.messages.at(-1)?.resultData?.resultUnseen).toBe(true); + }); });