Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 71 additions & 11 deletions source/cydo/agent/claude.d
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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-<idx>" 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;
}
}
}

Expand Down
4 changes: 3 additions & 1 deletion web/src/components/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
65 changes: 65 additions & 0 deletions web/src/sessionReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
33 changes: 33 additions & 0 deletions web/src/sessionReducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,13 +702,46 @@ export function reduceResultMessage(
permissionDenials: msg.permission_denials,
stopReason: msg.stop_reason,
errors: msg.errors,
resultUnseen: isResultTextUnseen(messages, blocks, msg.result),
},
},
],
};
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<string, Block>,
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. */
Expand Down
4 changes: 4 additions & 0 deletions web/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?: {
Expand Down