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:
parent
ef288e07b6
commit
faf43042bd
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in New Issue