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 618542993..332018d41 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 @@ -407,9 +407,19 @@ describe("Claude Code Activity Detection", () => { expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); }); - it("returns 'ready' for recent 'pr-link' (bookkeeping)", async () => { - writeJsonl([{ type: "pr-link" }]); - expect((await agent.getActivityState(makeSession()))?.state).toBe("ready"); + it("returns 'idle' (not 'ready') for recent 'pr-link' — re-snapshot noise", async () => { + // Empirical evidence (ao-160): the SAME PR (#1911) was written as + // pr-link 33 times in the last 200 lines, with new timestamps each + // time. It's a periodic state snapshot, not a one-shot event. + // Treat as noise so dormant sessions don't show as "ready" forever. + writeJsonl([ + { + type: "pr-link", + prNumber: 1911, + prUrl: "https://github.com/owner/repo/pull/1911", + }, + ]); + expect((await agent.getActivityState(makeSession()))?.state).toBe("idle"); }); it("returns 'ready' for recent 'attachment' (bookkeeping)", async () => { diff --git a/packages/plugins/agent-claude-code/src/activity-detection.ts b/packages/plugins/agent-claude-code/src/activity-detection.ts index 678357a52..8db9aa8bd 100644 --- a/packages/plugins/agent-claude-code/src/activity-detection.ts +++ b/packages/plugins/agent-claude-code/src/activity-detection.ts @@ -352,6 +352,14 @@ const NOISE_JSONL_TYPES: ReadonlySet = new Set([ "agent-color", "agent-name", "custom-title", + // pr-link is also re-snapshot noise — verified on ao-160's JSONL where the + // SAME PR (#1911) was written as a `pr-link` entry three times within + // minutes (count: 33 pr-link vs 21 user messages in the last 200 lines). + // The first emission is real; subsequent re-emissions are state snapshots. + // We can't distinguish first vs Nth from the last line alone, so treat + // all pr-link as noise. Real PR creation is still observable via the + // assistant message and the gh-tracker side. + "pr-link", ]); /** @@ -443,14 +451,14 @@ export async function getClaudeActivityState( case "summary": return { state: ageMs > threshold ? "idle" : "ready", timestamp }; - // 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. + // Bookkeeping types Claude writes AFTER a real event (file edits, + // attachment context, queue housekeeping, prompt submit). Map to + // ready/idle by age, same as assistant/summary. The pure re-snapshot + // types (permission-mode, ai-title, agent-*, custom-title, pr-link) + // are filtered out earlier by NOISE_JSONL_TYPES — they get written + // continuously without indicating activity. case "file-history-snapshot": case "attachment": - case "pr-link": case "queue-operation": case "last-prompt": return { state: ageMs > threshold ? "idle" : "ready", timestamp };