fix(agent-claude-code): map bookkeeping JSONL types to ready, not active

Claude writes several types AFTER finishing a turn — `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `permission-mode`, `last-prompt`, `ai-title`, `agent-color`, `agent-name`, `custom-title`. Until now they fell through to the `default` switch branch and looked `active` for 30s, making finished sessions appear busy. This was almost certainly the root cause of "Claude looks like it's still working when it's done" reports (the #1908 family).

Add explicit cases mapping each to `ready`/`idle` by age, same as `assistant`/`summary`. Three existing tests that asserted these returned `active` were testing the buggy behavior — updated to the correct behavior, plus four new tests covering `attachment`, `permission-mode`, and `ai-title`.
This commit is contained in:
harshitsinghbhandari 2026-05-19 18:11:38 +05:30
parent a610601158
commit 39c2b1ddea
2 changed files with 44 additions and 6 deletions

View File

@ -276,19 +276,38 @@ describe("Claude Code Activity Detection", () => {
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'active' for recent 'file-history-snapshot' (bookkeeping)", async () => {
// Bookkeeping types Claude writes AFTER finishing a turn — these are
// turn-end markers, not "Claude is working" signals. They must map to
// ready/idle by age, NOT active. Previously they fell through to the
// default branch and looked "active" for 30s; fixed in this PR.
it("returns 'ready' for recent 'file-history-snapshot' (bookkeeping)", async () => {
writeJsonl([{ type: "file-history-snapshot" }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'active' for recent 'queue-operation' (bookkeeping)", async () => {
it("returns 'ready' for recent 'queue-operation' (bookkeeping)", async () => {
writeJsonl([{ type: "queue-operation" }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'active' for recent 'pr-link' (bookkeeping)", async () => {
it("returns 'ready' for recent 'pr-link' (bookkeeping)", async () => {
writeJsonl([{ type: "pr-link" }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("active");
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'ready' for recent 'attachment' (bookkeeping)", async () => {
writeJsonl([{ type: "attachment", attachment: { type: "skill_listing" } }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'ready' for recent 'permission-mode' (bookkeeping)", async () => {
writeJsonl([{ type: "permission-mode", permissionMode: "bypassPermissions" }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
it("returns 'ready' for recent UI-metadata bookkeeping (ai-title)", async () => {
writeJsonl([{ type: "ai-title", title: "Fix login bug" }]);
expect((await agent.getActivityState(makeSession()))?.state).toBe("ready");
});
});

View File

@ -304,6 +304,25 @@ export async function getClaudeActivityState(
case "result":
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
// Bookkeeping types Claude writes AFTER finishing a turn (UI metadata,
// file snapshots, queue housekeeping). When one of these is the LAST
// entry, Claude is done — not active. Without these explicit cases
// they'd fall through to `default` and look `active` for 30s, which
// is the root cause of "Claude looks busy when it's done" reports.
// Survey of types observed in real ~/.claude/projects/ JSONL: see
// PR #1927 description.
case "file-history-snapshot":
case "attachment":
case "pr-link":
case "queue-operation":
case "permission-mode":
case "last-prompt":
case "ai-title":
case "agent-color":
case "agent-name":
case "custom-title":
return { state: ageMs > threshold ? "idle" : "ready", timestamp };
default:
if (ageMs <= activeWindowMs) return { state: "active", timestamp };
return { state: ageMs > threshold ? "idle" : "ready", timestamp };