diff --git a/source/cydo/agent/drivers/claude.d b/source/cydo/agent/drivers/claude.d index c328cfb..e8fed8a 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) @@ -1071,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 @@ -1079,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; @@ -1221,20 +1312,33 @@ 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) + { + // 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. + foreach (ev; promoteContentBlocks(raw.message.content, rawLine, promotedBlockSeq_)) + emitEvent(ev); + } + else { - auto frag = extrasToFragment(b._extras); - if (frag.json !is null && frag.json.length > 0) + // Cache per-block extras so content_block_stop can attach them. + foreach (idx, 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; - else - itemId = "cc-block-" ~ to!string(idx); - blockExtras_[itemId] = frag; + 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..5355759 100644 --- a/web/src/sessionReducer.test.ts +++ b/web/src/sessionReducer.test.ts @@ -574,3 +574,83 @@ 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); + }); + + 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); + }); +}); 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?: {