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 d7b4c8ac0..618542993 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 @@ -417,14 +417,41 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'ready' for recent 'permission-mode' (bookkeeping)", async () => { + // Pure UI-noise types (permission-mode, ai-title, agent-*, custom-title) + // are written by Claude at random times (session attach, mode change, + // title gen) and don't reflect actual activity. Real-world repro: + // ao-144 had 73 trailing permission-mode + 73 trailing ai-title entries + // over 6 dormant days, causing the dashboard to oscillate between + // ready and idle. They now fall through to the AO JSONL pipeline; if + // that has nothing, the stale-native fallback returns idle from + // session.createdAt. + it("returns 'idle' (not 'ready') for recent permission-mode noise — dormant session", async () => { writeJsonl([{ type: "permission-mode", permissionMode: "bypassPermissions" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); - it("returns 'ready' for recent UI-metadata bookkeeping (ai-title)", async () => { + it("returns 'idle' (not 'ready') for recent ai-title noise — dormant session", async () => { writeJsonl([{ type: "ai-title", title: "Fix login bug" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("returns 'idle' for agent-color / agent-name / custom-title noise", async () => { + writeJsonl([{ type: "agent-color", color: "#fff" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + writeJsonl([{ type: "agent-name", name: "ao-161" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + writeJsonl([{ type: "custom-title", title: "x" }]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); + }); + + it("noise last entry yields to AO JSONL when AO has actionable state", async () => { + // Repro of the scenario where Claude IS active but the latest native + // JSONL line happens to be noise. Native JSONL gets skipped, AO + // activity JSONL has waiting_input from a recent terminal scrape, + // cascade returns waiting_input. + writeJsonl([{ type: "permission-mode" }]); + writeActivityLog("waiting_input"); + expect((await agent.getActivityState(makeSession()))?.state).toBe("waiting_input"); }); }); diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts index 15ba934b8..776fbe084 100644 --- a/packages/plugins/agent-claude-code/src/activity-detection.ts +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -319,6 +319,35 @@ export function classifyTerminalOutput(terminalOutput: string): ActivityState { // Activity-state cascade // ============================================================================= +/** + * Claude writes these types as UI-state snapshots at random times: on session + * attach, on permission-mode change, on title regeneration, etc. They are + * NOT correlated with whether Claude is actively working — a 6-day-dormant + * session will still accumulate dozens of `permission-mode` and `ai-title` + * entries just from being inspected. + * + * When one of these is the literal last JSONL entry, treat it as a "no + * signal" — fall through to the AO activity-JSONL pipeline (terminal- + * derived signal) rather than letting noise mtime decide the activity. + * + * Concrete bug this prevents: ao-144 had 73 trailing `permission-mode` + + * 73 trailing `ai-title` entries written over 6 dormant days. Without + * this skip, dashboard oscillated between `ready` (recent noise mtime) + * and `idle` (old noise mtime) instead of staying `idle`. + * + * Conservative list — only the types that empirically run away. The other + * bookkeeping types (file-history-snapshot, attachment, pr-link, + * queue-operation, last-prompt) plausibly correlate with real activity + * and stay in the explicit ready/idle case. + */ +const NOISE_JSONL_TYPES: ReadonlySet = new Set([ + "permission-mode", + "ai-title", + "agent-color", + "agent-name", + "custom-title", +]); + /** * Determine current activity state for a Claude Code session. * @@ -370,6 +399,12 @@ export async function getClaudeActivityState( // Claude writes this session's first native JSONL entry. if (session.createdAt && entry.modifiedAt < session.createdAt) { staleNativeState = { state: "idle", timestamp: session.createdAt }; + } else if (entry.lastType && NOISE_JSONL_TYPES.has(entry.lastType)) { + // Last entry is UI-state noise (permission-mode / ai-title / etc.) + // that doesn't reflect actual activity. Fall through to the AO + // activity-JSONL pipeline for a terminal-derived answer; if that's + // also empty, the staleNativeState below returns idle. + staleNativeState = { state: "idle", timestamp: session.createdAt }; } else { const ageMs = Date.now() - entry.modifiedAt.getTime(); const timestamp = entry.modifiedAt; @@ -402,23 +437,16 @@ export async function getClaudeActivityState( case "summary": 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. + // Bookkeeping types Claude writes AFTER finishing a turn (file + // edits, PR creation, attachment context). Map to ready/idle by + // age, same as assistant/summary. Pure UI-noise types + // (permission-mode, ai-title, agent-*, custom-title) are handled + // separately by the NOISE_JSONL_TYPES check above the switch. 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: