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
150 changes: 127 additions & 23 deletions source/cydo/agent/drivers/claude.d
Original file line number Diff line number Diff line change
Expand Up @@ -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_;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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-<idx>" 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
Expand All @@ -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;
Expand Down Expand Up @@ -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;
}
}
}

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
80 changes: 80 additions & 0 deletions web/src/sessionReducer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
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