diff --git a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts index bc2735913..e38de91a7 100644 --- a/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts +++ b/packages/plugins/agent-claude-code/src/__tests__/activity-detection.test.ts @@ -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"); }); }); diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts index 3022c4011..ba6a96d11 100644 --- a/packages/plugins/agent-claude-code/src/activity-detection.ts +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -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 };