Skip to content
Merged
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
19 changes: 13 additions & 6 deletions src/collect/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,21 @@ export async function agentContext(path: string) {
// caller drives it as a floating promise, so a throw here would take the
// whole TUI down over one bad line. Whatever was read stays usable.
try {
// mid-flight: a tool call was issued (awaiting its result) or a result
// just arrived (awaiting the next turn). A final text-only assistant turn
// means the agent finished, so the mtime window alone governs it. Derived
// before the message contents below, which walk arbitrary blocks: a
// malformed one there must not cost a live agent its row.
// mid-flight: a tool call was issued (awaiting its result), a result just
// arrived (awaiting the next turn), or the message is still streaming.
// Claude Code writes each content block as its own entry and only the
// last carries a stop_reason; the earlier ones record an explicit null.
// So a text block with stop_reason null is mid-message — typically
// narration while the model generates a long tool call (a big Write),
// which can take minutes. A final text-only turn (end_turn) means the
// agent finished, so the mtime window alone governs it. Derived before
// the message contents below, which walk arbitrary blocks: a malformed
// one there must not cost a live agent its row.
out.running =
!!last &&
((last.type === "assistant" && hasBlock(last.message, "tool_use")) ||
((last.type === "assistant" &&
(hasBlock(last.message, "tool_use") ||
last.message?.stop_reason === null)) ||
(last.type === "user" && hasBlock(last.message, "tool_result")));
if (lastUsage) {
out.model = lastUsage.model;
Expand Down
25 changes: 25 additions & 0 deletions test/collect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,6 +629,31 @@ describe("transcript file scanning", () => {
expect(ctx.running).toBe(false);
});

// Claude Code writes each content block as its own entry; only the final
// one carries a stop_reason, the rest an explicit null. Narration streamed
// ahead of a long tool call must keep the agent live; end_turn ends it.
test("treats a mid-message text block as running", async () => {
const block = (text: string, stop_reason: string | null) => ({
type: "assistant",
message: {
model: "claude-opus-5",
usage: { input_tokens: 8 },
content: [{ type: "text", text }],
stop_reason,
},
});
const streaming = write(
"agent-3.jsonl",
jsonl([block("Now the recorder script.", null)]),
);
expect((await __test.agentContext(streaming)).running).toBe(true);
const finished = write(
"agent-4.jsonl",
jsonl([block("all done", "end_turn")]),
);
expect((await __test.agentContext(finished)).running).toBe(false);
});

// collectRows runs from a floating promise, so a throw out of here would
// take the whole TUI down over one malformed line in one agent transcript
test("survives a malformed entry instead of throwing", async () => {
Expand Down
Loading