From 17db71c43c0e1568fb5d02d5690986d83075072a Mon Sep 17 00:00:00 2001 From: Haileyesus <118998054+blackmammoth@users.noreply.github.com> Date: Fri, 8 May 2026 19:23:07 +0300 Subject: [PATCH] fix(claude): preserve local command artifacts in session history Claude writes slash-command metadata, local stdout, and compaction summaries into the same JSONL stream as normal chat messages. The existing normalization path treated those rows as internal content and dropped them entirely. That made the web UI diverge from the CLI transcript and removed important context. Commands like /compact appeared to have never happened, the stdout status line disappeared, and the continuation summary after compaction was filtered out even though it best describes the post-boundary session state. This change keeps the distinction between truly internal transcript rows and user-visible local command artifacts. Command wrapper tags are parsed into structured metadata without exposing the raw tags, local command stdout is remapped to assistant text, and compact summaries are preserved as assistant-authored content instead of being mislabeled as user input. Search and session-summary parsing are updated for the same reason. If history normalization preserved these rows but search still ignored them, rendered conversation state and searchable conversation state would continue to disagree, and session summaries would fall back to stale user text instead of Claude's actual compaction summary. The shared message and store typings are extended so this metadata survives the full backend-to-frontend pipeline. That avoids reconstructing meaning later and keeps the transcript faithful to Claude's persisted history while still hiding genuinely internal control content. --- .../list/claude/claude-sessions.provider.ts | 156 +++++++++++++++++- .../session-conversations-search.service.ts | 153 +++++++++++++++-- server/shared/types.ts | 15 ++ src/components/chat/hooks/useChatMessages.ts | 24 ++- src/components/chat/types/types.ts | 7 + src/stores/useSessionStore.ts | 14 ++ 6 files changed, 347 insertions(+), 22 deletions(-) diff --git a/server/modules/providers/list/claude/claude-sessions.provider.ts b/server/modules/providers/list/claude/claude-sessions.provider.ts index 94215257..f803d92c 100644 --- a/server/modules/providers/list/claude/claude-sessions.provider.ts +++ b/server/modules/providers/list/claude/claude-sessions.provider.ts @@ -200,17 +200,18 @@ async function getSessionMessages( } /** - * Claude writes internal command and system reminder entries into history. - * Those are useful for the CLI but should not appear in the user-facing chat. + * Claude writes a mix of truly internal transcript rows and "UI-hidden" local + * command artifacts into the same JSONL stream. + * + * Important distinction: + * - system reminders / caveats / interruption banners should stay hidden + * - local command payloads (`...`) and stdout wrappers + * (`...`) should be remapped into normal chat messages + * instead of being discarded as internal content */ const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', '[Request interrupted', ] as const; @@ -218,6 +219,73 @@ function isInternalContent(content: string): boolean { return INTERNAL_CONTENT_PREFIXES.some((prefix) => content.startsWith(prefix)); } +/** + * Claude wraps local slash-command metadata in lightweight XML-like tags inside + * a plain string payload. We intentionally parse only the small tag surface we + * care about instead of introducing a generic XML parser for untrusted history. + */ +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +/** + * Converts Claude's hidden local command wrapper into structured metadata. + * + * The three tags often coexist in one string payload. Returning `null` lets the + * normal text path continue untouched for unrelated messages. + */ +function parseLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +/** + * Produces the short user-visible command string that should appear in chat. + * + * We prefer the slash-prefixed command name because that most closely matches + * what the user actually typed, and only fall back to the message body when the + * command name is unavailable in older transcript variants. + */ +function buildLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +/** + * Claude local-command stdout may contain ANSI styling codes because it was + * captured from the terminal. The web chat should receive readable plain text. + */ +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + export class ClaudeSessionsProvider implements IProviderSessions { /** * Normalizes one Claude JSONL entry or live SDK stream event into the shared @@ -293,6 +361,80 @@ export class ClaudeSessionsProvider implements IProviderSessions { } } else if (typeof raw.message.content === 'string') { const text = raw.message.content; + + /** + * Claude stores compact summaries as synthetic "user" rows so the CLI + * can resume the next session turn with the summary in-context. + * + * For the web UI this is much more useful as assistant-authored summary + * text; otherwise it is both filtered by the generic internal-prefix + * check and visually mislabeled as a user message. + */ + if (raw.isCompactSummary === true && text.trim()) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: text, + isCompactSummary: true, + })); + return messages; + } + + /** + * Local slash commands are serialized as tagged text even though they + * are semantically a user action. Expose the parsed fields to the + * frontend and emit a plain user-visible command string so the command + * no longer disappears from history. + */ + const localCommandPayload = parseLocalCommandPayload(text); + if (localCommandPayload) { + const displayText = buildLocalCommandDisplayText(localCommandPayload); + if (displayText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'user', + content: displayText, + commandName: localCommandPayload.commandName, + commandMessage: localCommandPayload.commandMessage, + commandArgs: localCommandPayload.commandArgs, + isLocalCommand: true, + })); + } + return messages; + } + + /** + * Local command stdout is also written as a "user" row in Claude's + * transcript, but it is terminal output produced in response to the + * command. Re-label it as assistant text so the chat transcript matches + * the actual conversational flow seen by the user. + */ + const localCommandStdout = extractTaggedContent(text, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + if (stdoutText) { + messages.push(createNormalizedMessage({ + id: baseId, + sessionId, + timestamp: ts, + provider: PROVIDER, + kind: 'text', + role: 'assistant', + content: stdoutText, + isLocalCommandStdout: true, + })); + } + return messages; + } + if (text && !isInternalContent(text)) { messages.push(createNormalizedMessage({ id: baseId, diff --git a/server/modules/providers/services/session-conversations-search.service.ts b/server/modules/providers/services/session-conversations-search.service.ts index afc8bdac..38492201 100644 --- a/server/modules/providers/services/session-conversations-search.service.ts +++ b/server/modules/providers/services/session-conversations-search.service.ts @@ -89,13 +89,8 @@ const RIPGREP_CHUNK_CONCURRENCY = 6; const UNKNOWN_PROJECT_KEY = '__unknown_project__'; const INTERNAL_CONTENT_PREFIXES = [ - '', - '', - '', - '', '', 'Caveat:', - 'This session is being continued from a previous', 'Invalid API key', '[Request interrupted', ] as const; @@ -302,6 +297,135 @@ function extractClaudeText(content: unknown): string { .join(' '); } +function extractTaggedContent(content: string, tagName: string): string | null { + const escapedTagName = tagName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const match = new RegExp(`<${escapedTagName}>([\\s\\S]*?)<\\/${escapedTagName}>`).exec(content); + return match ? match[1] : null; +} + +type ClaudeLocalCommandPayload = { + commandName: string; + commandMessage: string; + commandArgs: string; +}; + +function parseClaudeLocalCommandPayload(content: string): ClaudeLocalCommandPayload | null { + const commandName = extractTaggedContent(content, 'command-name'); + const commandMessage = extractTaggedContent(content, 'command-message'); + const commandArgs = extractTaggedContent(content, 'command-args'); + + if (commandName === null && commandMessage === null && commandArgs === null) { + return null; + } + + return { + commandName: commandName ?? '', + commandMessage: commandMessage ?? '', + commandArgs: commandArgs ?? '', + }; +} + +function buildClaudeLocalCommandDisplayText(payload: ClaudeLocalCommandPayload): string { + const commandName = payload.commandName.trim(); + const commandMessage = payload.commandMessage.trim(); + const commandArgs = payload.commandArgs.trim(); + const baseCommand = commandName || commandMessage; + + if (!baseCommand) { + return ''; + } + + return commandArgs ? `${baseCommand} ${commandArgs}` : baseCommand; +} + +function stripAnsiFormatting(text: string): string { + return text.replace(/\u001B\[[0-9;?]*[ -/]*[@-~]/g, ''); +} + +type ClaudeSearchableMessage = { + text: string; + role: 'user' | 'assistant'; +}; + +/** + * Claude mixes visible chat, compact summaries, and local command wrappers into + * the same transcript stream. Search should operate on the user-visible meaning + * of those rows rather than the raw wrapper syntax. + */ +function extractClaudeSearchableMessage(entry: AnyRecord): ClaudeSearchableMessage | null { + if (!entry.message?.content || entry.isApiErrorMessage) { + return null; + } + + const rawRole = entry.message.role; + if (rawRole !== 'user' && rawRole !== 'assistant') { + return null; + } + + if (typeof entry.message.content === 'string') { + const content = String(entry.message.content); + + if (entry.isCompactSummary === true && content.trim()) { + return { + text: content, + role: 'assistant', + }; + } + + const localCommand = parseClaudeLocalCommandPayload(content); + if (localCommand) { + const displayText = buildClaudeLocalCommandDisplayText(localCommand); + return displayText + ? { + text: displayText, + role: 'user', + } + : null; + } + + const localCommandStdout = extractTaggedContent(content, 'local-command-stdout'); + if (localCommandStdout !== null) { + const stdoutText = stripAnsiFormatting(localCommandStdout).trim(); + return stdoutText + ? { + text: stdoutText, + role: 'assistant', + } + : null; + } + + if (!content || isInternalContent(content)) { + return null; + } + + return { + text: content, + role: rawRole, + }; + } + + const text = extractClaudeText(entry.message.content); + if (!text) { + return null; + } + + if (entry.isCompactSummary === true) { + return { + text, + role: 'assistant', + }; + } + + if (isInternalContent(text)) { + return null; + } + + return { + text, + role: rawRole, + }; +} + function extractCodexText(content: unknown): string { if (typeof content === 'string') { return content; @@ -733,18 +857,21 @@ async function parseClaudeSessionMatches( } } - if (!entry.message?.content || entry.isApiErrorMessage) { + const searchableMessage = extractClaudeSearchableMessage(entry); + if (!searchableMessage) { continue; } - const role = entry.message.role; - if (role !== 'user' && role !== 'assistant') { - continue; - } + const { text, role } = searchableMessage; - const text = extractClaudeText(entry.message.content); - if (!text || isInternalContent(text)) { - continue; + /** + * Claude compact summaries are the most faithful session-summary source + * after a `/compact` because they describe the post-compaction state that + * the resumed session actually continues from. Prefer them over generic + * fallback user text when present. + */ + if (entry.isCompactSummary === true) { + state.resolvedSummary = text; } if (role === 'user') { diff --git a/server/shared/types.ts b/server/shared/types.ts index d15f69e7..af09abf2 100644 --- a/server/shared/types.ts +++ b/server/shared/types.ts @@ -102,6 +102,21 @@ export type NormalizedMessage = { kind: MessageKind; role?: 'user' | 'assistant'; content?: string; + /** + * Optional display-oriented metadata used by providers that need to expose + * richer transcript artifacts without introducing a brand-new message kind. + * + * Current Claude usage: + * - local slash commands expose parsed command fields + * - compact summaries are flagged so the UI can treat them differently later + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: unknown; toolName?: string; toolInput?: unknown; diff --git a/src/components/chat/hooks/useChatMessages.ts b/src/components/chat/hooks/useChatMessages.ts index 8f417de5..1590c4af 100644 --- a/src/components/chat/hooks/useChatMessages.ts +++ b/src/components/chat/hooks/useChatMessages.ts @@ -11,8 +11,9 @@ import { decodeHtmlEntities, unescapeWithMathProtection, formatUsageLimitText } * Convert NormalizedMessage[] from the session store into ChatMessage[] * that the existing UI components expect. * - * Internal/system content (e.g. , ) is already - * filtered server-side by the Claude provider module. + * Truly internal/system content is already filtered server-side. Some Claude + * transcript artifacts such as local slash commands and compact summaries are + * intentionally preserved and annotated so they can render like normal chat. */ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMessage[] { const converted: ChatMessage[] = []; @@ -26,6 +27,16 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes } for (const msg of messages) { + const sharedMetadata = { + displayText: msg.displayText, + commandName: msg.commandName, + commandMessage: msg.commandMessage, + commandArgs: msg.commandArgs, + isLocalCommand: msg.isLocalCommand, + isLocalCommandStdout: msg.isLocalCommandStdout, + isCompactSummary: msg.isCompactSummary, + }; + switch (msg.kind) { case 'text': { const content = msg.content || ''; @@ -42,12 +53,14 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: taskNotifMatch[1]?.trim() || 'completed', + ...sharedMetadata, }); } else { converted.push({ type: 'user', content: unescapeWithMathProtection(decodeHtmlEntities(content)), timestamp: msg.timestamp, + ...sharedMetadata, }); } } else { @@ -58,6 +71,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'assistant', content: text, timestamp: msg.timestamp, + ...sharedMetadata, }); } break; @@ -106,6 +120,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes isComplete: Boolean(toolResult), } : undefined, + ...sharedMetadata, }); break; } @@ -117,6 +132,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: unescapeWithMathProtection(msg.content), timestamp: msg.timestamp, isThinking: true, + ...sharedMetadata, }); } break; @@ -126,6 +142,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes type: 'error', content: msg.content || 'Unknown error', timestamp: msg.timestamp, + ...sharedMetadata, }); break; @@ -135,6 +152,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content || '', timestamp: msg.timestamp, isInteractivePrompt: true, + ...sharedMetadata, }); break; @@ -145,6 +163,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes timestamp: msg.timestamp, isTaskNotification: true, taskStatus: msg.status || 'completed', + ...sharedMetadata, }); break; @@ -155,6 +174,7 @@ export function normalizedToChatMessages(messages: NormalizedMessage[]): ChatMes content: msg.content, timestamp: msg.timestamp, isStreaming: true, + ...sharedMetadata, }); } break; diff --git a/src/components/chat/types/types.ts b/src/components/chat/types/types.ts index f8d492e5..474f23e1 100644 --- a/src/components/chat/types/types.ts +++ b/src/components/chat/types/types.ts @@ -28,6 +28,7 @@ export interface SubagentChildTool { export interface ChatMessage { type: string; content?: string; + displayText?: string; timestamp: string | number | Date; images?: ChatImage[]; reasoning?: string; @@ -40,6 +41,12 @@ export interface ChatMessage { toolResult?: ToolResult | null; toolId?: string; toolCallId?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; isSubagentContainer?: boolean; subagentState?: { childTools: SubagentChildTool[]; diff --git a/src/stores/useSessionStore.ts b/src/stores/useSessionStore.ts index 86925048..1a720d3d 100644 --- a/src/stores/useSessionStore.ts +++ b/src/stores/useSessionStore.ts @@ -40,6 +40,20 @@ export interface NormalizedMessage { // kind-specific fields (flat for simplicity) role?: 'user' | 'assistant'; content?: string; + /** + * Mirrors optional transcript metadata from the server. + * + * These fields are currently used by Claude history normalization so local + * slash commands, local stdout, and compact summaries do not disappear when + * the session store hydrates from REST history. + */ + displayText?: string; + commandName?: string; + commandMessage?: string; + commandArgs?: string; + isLocalCommand?: boolean; + isLocalCommandStdout?: boolean; + isCompactSummary?: boolean; images?: string[]; toolName?: string; toolInput?: unknown;