From 66b9cf634c8fc0c1ebf48086c3695647f660aaea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 19:40:29 +0000 Subject: [PATCH] keep background task notifications out of the transcript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness delivers a finished background task to the agent by writing it as the user's next message, so Claude's file holds a raw envelope as a user turn. Live it never shows, because the same event travels separately as task:notification — it appeared only when a refresh rebuilt the conversation from the file, as a bubble on the owner's side he never typed. Same defect as INTERRUPTION_MARKERS and the same fix. Anchored to the start of the message so quoting one inside a real message stays yours. Also skipped when picking a session's title, where it is no more a title than a slash command is. Verified against a live transcript: 38 user bubbles before, 32 after, the 6 removed being exactly the notifications. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/claude-sessions.ts | 31 +++++++++++++++++++++---- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index cded3340..6e52cf22 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -178,7 +178,11 @@ function summarizeTranscript(filePath: string, id: string): TranscriptSummary | sawFirstUserEntry = true; bornFromClear = text.startsWith('/clear'); } - if (text && !firstUserText && !text.startsWith('')) firstUserText = text; + // A resumed session can open with a background task reporting in, which is no more a title than a + // slash command is — see `isTaskNotification`. + if (text && !firstUserText && !text.startsWith('') && !isTaskNotification(text)) { + firstUserText = text; + } } } } @@ -384,8 +388,23 @@ function blockText(content: unknown): string { return ''; } -const userOrInterruption = (text: string): ClaudeChatMessage => - INTERRUPTION_MARKERS.has(text.trim()) ? { role: 'interrupted' } : { role: 'user', text }; +/** + * A background task reporting in. The harness delivers these to the agent by writing them as the *user's* + * next message — the same trick as `INTERRUPTION_MARKERS`, and the same consequence: replayed literally, + * the transcript shows a raw XML envelope in a bubble on your side of the conversation that you never + * typed. Live it never appears, because a finished task travels as its own `task:notification` event; it + * surfaces only when a refresh rebuilds the conversation from Claude's file. + * + * Anchored to the start of the message rather than matched anywhere in it, so quoting one inside a real + * message — this file's own conversation does exactly that — stays yours. + */ +const isTaskNotification = (text: string) => text.trimStart().startsWith(''); + +function userMessageFrom(text: string): ClaudeChatMessage | null { + if (isTaskNotification(text)) return null; + if (INTERRUPTION_MARKERS.has(text.trim())) return { role: 'interrupted' }; + return { role: 'user', text }; +} export type ClaudeSessionDetail = { id: string; model: string; cwd: string; messages: ClaudeChatMessage[] }; @@ -430,13 +449,15 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd if (entry.type === 'user' && !entry.isMeta) { if (typeof content === 'string') { - if (content.trim()) messages.push(userOrInterruption(content)); + const message = content.trim() ? userMessageFrom(content) : null; + if (message) messages.push(message); continue; } if (Array.isArray(content)) { for (const block of content as ContentBlock[]) { if (block.type === 'text' && block.text?.trim()) { - messages.push(userOrInterruption(block.text)); + const message = userMessageFrom(block.text); + if (message) messages.push(message); } else if (block.type === 'tool_result') { const tool = toolById.get(block.tool_use_id); if (tool) {