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.
This commit is contained in:
Haileyesus
2026-05-08 19:23:07 +03:00
parent 116f91bc3a
commit 17db71c43c
6 changed files with 347 additions and 22 deletions

View File

@@ -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 (`<command-name>...`) and stdout wrappers
* (`<local-command-stdout>...`) should be remapped into normal chat messages
* instead of being discarded as internal content
*/
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'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,

View File

@@ -89,13 +89,8 @@ const RIPGREP_CHUNK_CONCURRENCY = 6;
const UNKNOWN_PROJECT_KEY = '__unknown_project__';
const INTERNAL_CONTENT_PREFIXES = [
'<command-name>',
'<command-message>',
'<command-args>',
'<local-command-stdout>',
'<system-reminder>',
'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') {