fix(agent-claude-code): skip UI-noise JSONL types when reading last entry

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.
This commit is contained in:
harshitsinghbhandari 2026-05-19 19:20:57 +05:30
parent ef288e07b6
commit faf43042bd
2 changed files with 71 additions and 16 deletions

View File

@ -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");
});
});

View File

@ -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<string> = 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: