diff --git a/cli/src/claude/claudeLocalLauncher.test.ts b/cli/src/claude/claudeLocalLauncher.test.ts index 9267e29a..711ca09b 100644 --- a/cli/src/claude/claudeLocalLauncher.test.ts +++ b/cli/src/claude/claudeLocalLauncher.test.ts @@ -144,6 +144,16 @@ describe('claudeLocalLauncher message filtering', () => { expect(sentMessages).toHaveLength(2) }) + it('forwards away_summary (auto recap) system messages', async () => { + const { session, sentMessages } = createSessionStub() + await claudeLocalLauncher(session as never) + + harness.scannerOnMessage!({ type: 'system', subtype: 'away_summary', uuid: '1', content: 'recap text' }) + + expect(sentMessages).toHaveLength(1) + expect(sentMessages[0]).toMatchObject({ subtype: 'away_summary', content: 'recap text' }) + }) + it('forwards normal conversation messages', async () => { const { session, sentMessages } = createSessionStub() await claudeLocalLauncher(session as never) diff --git a/cli/src/claude/types.test.ts b/cli/src/claude/types.test.ts index 62e7490a..b9595e00 100644 --- a/cli/src/claude/types.test.ts +++ b/cli/src/claude/types.test.ts @@ -93,4 +93,21 @@ describe("RawJSONLinesSchema", () => { expect((parsed as Record).futureBreakdown).toEqual({ tokens: { in: 1, out: 2 } }); }); }); + + describe("system / away_summary record", () => { + it("preserves the recap text in `content` (not declared on the base schema, relies on passthrough)", () => { + // Claude code's away_summary record carries the recap text in `content`. + // If Zod strips undeclared fields, the recap text never reaches the hub/web. + const parsed = RawJSONLinesSchema.parse({ + type: "system", + subtype: "away_summary", + uuid: "evt-4", + content: "Building X, next: wire up Y.", + timestamp: "2026-07-12T00:00:00.000Z", + isMeta: false + }); + if (parsed.type !== "system") throw new Error("expected system record"); + expect((parsed as Record).content).toBe("Building X, next: wire up Y."); + }); + }); }); diff --git a/cli/src/claude/utils/chatVisibility.test.ts b/cli/src/claude/utils/chatVisibility.test.ts index f85c4deb..558b8c0b 100644 --- a/cli/src/claude/utils/chatVisibility.test.ts +++ b/cli/src/claude/utils/chatVisibility.test.ts @@ -13,6 +13,7 @@ describe('isClaudeChatVisibleMessage', () => { expect(isClaudeChatVisibleMessage({ type: 'system', subtype: 'api_error' })).toBe(true) expect(isClaudeChatVisibleMessage({ type: 'system', subtype: 'microcompact_boundary' })).toBe(true) expect(isClaudeChatVisibleMessage({ type: 'system', subtype: 'compact_boundary' })).toBe(true) + expect(isClaudeChatVisibleMessage({ type: 'system', subtype: 'away_summary' })).toBe(true) }) it('keeps conversation messages visible', () => { diff --git a/shared/src/messages.ts b/shared/src/messages.ts index 7b65149d..44bd0053 100644 --- a/shared/src/messages.ts +++ b/shared/src/messages.ts @@ -10,7 +10,13 @@ const VISIBLE_CLAUDE_SYSTEM_SUBTYPES = new Set([ 'api_error', 'turn_duration', 'microcompact_boundary', - 'compact_boundary' + 'compact_boundary', + // Auto-generated recap Claude Code's local TUI writes to the transcript on + // window blur/focus (5min+ idle). Only observed via the local launcher's + // transcript scan — SDK/remote mode never emits it. Chat-visible here also + // means CLI-forwarded, web-rendered, and included in session export + // (parity with turn_duration / compact_boundary). + 'away_summary' ]) export function isRoleWrappedRecord(value: unknown): value is RoleWrappedRecord { diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 6b9b0a4e..2ca56e0b 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -71,6 +71,65 @@ describe('normalizeDecryptedMessage', () => { }) }) + it('normalizes away_summary (auto recap) system output into a recap event', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'system', + subtype: 'away_summary', + uuid: 'sys-3', + content: 'Building the login flow, next: wire up the submit handler.' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'event', + isSidechain: false, + content: { + type: 'recap', + text: 'Building the login flow, next: wire up the submit handler.' + } + }) + }) + + it('skips away_summary with empty content instead of emitting a bare recap row', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'system', + subtype: 'away_summary', + uuid: 'sys-4', + content: '' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toBeNull() + }) + + it('skips away_summary with whitespace-only content instead of emitting a bare recap row', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'output', + data: { + type: 'system', + subtype: 'away_summary', + uuid: 'sys-5', + content: ' ' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toBeNull() + }) + it('keeps the stringify fallback for unknown non-system agent payloads', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index 1378af90..200e689c 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -421,6 +421,10 @@ export function isSkippableAgentContent(content: unknown): boolean { const data = isObject(content.data) ? content.data : null if (!data) return false if (Boolean(data.isMeta) || Boolean(data.isCompactSummary)) return true + // A recap with no text is pure noise — drop it here rather than let it reach + // the away_summary branch (a bare "recap:" row) or, via a null return, fall + // through to the raw-JSON stringify fallback in normalize.ts. + if (data.type === 'system' && data.subtype === 'away_summary' && !asString(data.content)?.trim()) return true return !isClaudeChatVisibleMessage({ type: data.type, subtype: data.subtype }) } @@ -494,6 +498,22 @@ export function normalizeAgentRecord( meta } } + if (data.type === 'system' && data.subtype === 'away_summary') { + // Recap text lives in `content`. Empty recaps are dropped upstream by + // isSkippableAgentContent, so content is a non-empty string here. + return { + id: messageId, + localId, + createdAt, + role: 'event', + content: { + type: 'recap', + text: asString(data.content) ?? '' + }, + isSidechain: false, + meta + } + } if (data.type === 'system' && data.subtype === 'microcompact_boundary') { const metadata = isObject(data.microcompactMetadata) ? data.microcompactMetadata : null return { diff --git a/web/src/chat/presentation.test.ts b/web/src/chat/presentation.test.ts index af4497fa..e444b5f5 100644 --- a/web/src/chat/presentation.test.ts +++ b/web/src/chat/presentation.test.ts @@ -134,6 +134,18 @@ describe('getEventPresentation — thread goals', () => { }) }) +describe('getEventPresentation — recap (away_summary)', () => { + it('formats the recap with a recap: prefix', () => { + const result = getEventPresentation({ + type: 'recap', + text: 'Building the login flow, next: wire up the submit handler.' + }) + + expect(result.icon).toBe('💭') + expect(result.text).toBe('recap: Building the login flow, next: wire up the submit handler.') + }) +}) + describe('formatResetTime', () => { it('formats a unix timestamp to a non-empty string', () => { const result = formatResetTime(1774278000) diff --git a/web/src/chat/presentation.ts b/web/src/chat/presentation.ts index c9450f6e..28a750ce 100644 --- a/web/src/chat/presentation.ts +++ b/web/src/chat/presentation.ts @@ -200,6 +200,11 @@ export function getEventPresentation(event: AgentEvent): EventPresentation { if (event.type === 'compact') { return { icon: '📦', text: 'Conversation compacted' } } + if (event.type === 'recap') { + // Lowercase `recap:` intentionally mirrors Claude Code's own TUI recap label. + const text = typeof event.text === 'string' ? event.text : '' + return { icon: '💭', text: `recap: ${text}` } + } if (event.type === 'thread-goal-updated') { return formatThreadGoalEvent(event) } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 5dc934d7..8b003cb0 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -25,6 +25,8 @@ export type AgentEvent = | { type: 'turn-duration'; durationMs: number; targetMessageId?: string } | { type: 'microcompact'; trigger: string; preTokens: number; tokensSaved: number } | { type: 'compact'; trigger: string; preTokens: number } + // Claude Code's automatic away-summary recap (TUI window blur 5min+, then focus). + | { type: 'recap'; text: string } | { type: 'thread-goal-updated'; goal: ThreadGoal; threadId?: string; turnId?: string } | { type: 'thread-goal-cleared'; threadId?: string } | ({ type: string } & Record)