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; + } } } 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?: {