From b0c6db6f052afb776161264b0c97c7d0a9848b98 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/2] 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 e7ed4d20cd48903f05ae4d774407d39867628d8c 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/2] 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/claude.d | 82 +++++++++++++++++++++++++++++++++----- 1 file changed, 71 insertions(+), 11 deletions(-) diff --git a/source/cydo/agent/claude.d b/source/cydo/agent/claude.d index 3428682..fb8d946 100644 --- a/source/cydo/agent/claude.d +++ b/source/cydo/agent/claude.d @@ -688,6 +688,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_; @@ -892,6 +893,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) @@ -1205,20 +1216,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.agent.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; + } } }