From faf43042bded14cbf7b19c3259547c6932f21ebf Mon Sep 17 00:00:00 2001 From: harshitsinghbhandari <24b4506@iitb.ac.in> Date: Tue, 19 May 2026 19:20:57 +0530 Subject: [PATCH] fix(agent-claude-code): skip UI-noise JSONL types when reading last entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression caused by the earlier bookkeeping-types fix on this branch (commit 39c2b1dd). That commit moved `permission-mode`, `ai-title`, `agent-color`, `agent-name`, `custom-title` into the explicit ready/idle bookkeeping case alongside `file-history-snapshot` etc. — but these five types are UI-state snapshots Claude writes at random times (session attach, permission-mode change, title regeneration). They are NOT correlated with real activity. Empirical proof: ao-144 had 73 trailing `permission-mode` + 73 trailing `ai-title` entries written over 6 dormant days. Last real assistant message was 2026-05-13 12:57. With the earlier fix in place, the dashboard oscillated between `ready` (recent noise mtime) and `idle` (older noise mtime) instead of staying `idle`. User reported "I am not seeing active but ready for all session" — this is what they were seeing. Fix: define `NOISE_JSONL_TYPES` containing the five UI-state types. When the literal last entry is one of these, skip the native-JSONL classification and fall through to the AO activity-JSONL pipeline (terminal-derived). If that's also empty, the existing `staleNativeState` fallback returns `idle` from `session.createdAt`, which is correct for dormant sessions. For sessions that ARE actively working but happen to have a noise type as the latest line, the AO JSONL fallback's terminal scrape catches the active state. Removed the five noise types from the explicit bookkeeping case in the switch. Kept `file-history-snapshot`, `attachment`, `pr-link`, `queue-operation`, `last-prompt` — these plausibly correlate with real activity (file edits, PR creation, prompt submission). Tests: updated `permission-mode → ready` to `→ idle`, same for `ai-title`. Added explicit tests for the other three noise types and a test confirming the AO JSONL pipeline still wins when last native entry is noise but AO has actionable state. --- .../src/__tests__/activity-detection.test.ts | 35 +++++++++++-- .../src/activity-detection.ts | 52 ++++++++++++++----- 2 files changed, 71 insertions(+), 16 deletions(-) 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: