mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
|
||||
Reference in New Issue
Block a user