feat(cli,web): show Claude Code's away recap in local-mode chat (#1089)

* feat(shared,cli): whitelist away_summary so auto recap reaches the hub

Claude Code's local TUI writes an automatic away-summary recap to the
session transcript on window blur/focus (5min+ idle), but
VISIBLE_CLAUDE_SYSTEM_SUBTYPES dropped it before it ever reached the
hub. Add it to the whitelist so the local launcher forwards it like
the other system subtypes, and cover the forwarding + Zod passthrough
of the recap `content` field with tests.

* feat(web): render Claude Code's automatic away recap in the chat

Once away_summary reaches the hub (previous commit), the web chat
still dropped it silently: normalizeAgent had no branch for the
subtype, so it fell through to `return null`. Add a `recap` AgentEvent,
a normalizeAgent branch mirroring the existing turn_duration/compact
subtype branches, and a presentation entry that prefixes the text with
`recap:` so it reads distinctly from the manual /recap assistant
bubble (which already renders as a normal message). No new render
component needed: it flows through the existing generic system-event
row (SystemMessage.tsx + getEventPresentation) that every other system
subtype already uses.

* fix(web): drop inaccurate manual-/recap comparison from recap comments
This commit is contained in:
Junmo Kim
2026-07-19 12:24:32 +08:00
committed by GitHub
parent 77f94ef738
commit 289c9f2218
9 changed files with 133 additions and 1 deletions
@@ -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)
+17
View File
@@ -93,4 +93,21 @@ describe("RawJSONLinesSchema", () => {
expect((parsed as Record<string, unknown>).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<string, unknown>).content).toBe("Building X, next: wire up Y.");
});
});
});
@@ -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', () => {
+7 -1
View File
@@ -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 {
+59
View File
@@ -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',
+20
View File
@@ -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 {
+12
View File
@@ -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)
+5
View File
@@ -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)
}
+2
View File
@@ -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<string, unknown>)