From c6be08e4710273c102af5d7ec6d6da1d8fa2b746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 2 Jul 2026 21:00:27 +0800 Subject: [PATCH] fix(desktop): restore skill command history (#926) Preserve user-invoked skill command metadata during session restore, WebSocket replay, and session search while continuing to hide malformed command breadcrumbs. Tested: - bun test src/server/__tests__/sessions.test.ts - bun test src/server/__tests__/ws-memory-events.test.ts - bun test src/server/__tests__/searchService.sessions.test.ts - cd desktop && bun run test -- --run src/stores/chatStore.test.ts - bun run check:server - cd desktop && bun run build - git diff --check Not-tested: - bun run check:desktop did not complete because an existing MessageList timestamp assertion fails independently of this change. Confidence: high Scope-risk: narrow --- desktop/src/stores/chatStore.test.ts | 45 +++++++- desktop/src/stores/chatStore.ts | 86 ++++++++++++++- .../__tests__/searchService.sessions.test.ts | 20 ++++ src/server/__tests__/sessions.test.ts | 49 ++++++++- src/server/__tests__/ws-memory-events.test.ts | 17 ++- src/server/services/searchService.ts | 23 ++-- src/server/services/sessionService.ts | 16 +-- src/server/ws/handler.ts | 35 ++---- src/utils/commandMetadata.ts | 103 ++++++++++++++++++ 9 files changed, 327 insertions(+), 67 deletions(-) create mode 100644 src/utils/commandMetadata.ts diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 10af709a..4f8c78cb 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -572,7 +572,7 @@ describe('chatStore history mapping', () => { ]) }) - it('does not restore internal slash-command breadcrumbs as user history bubbles', () => { + it('restores slash-command metadata as readable history while skipping malformed breadcrumbs', () => { const messages: MessageEntry[] = [ { id: 'agent-command-string', @@ -599,6 +599,12 @@ describe('chatStore history mapping', () => { }, ], }, + { + id: 'malformed-command', + type: 'user', + timestamp: '2026-06-15T03:32:14.500Z', + content: '/agent malformed breadcrumb', + }, { id: 'transcript-user-1', type: 'user', @@ -608,6 +614,16 @@ describe('chatStore history mapping', () => { ] expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'agent-command-string', + type: 'user_text', + content: '/agent Plan 222', + }, + { + id: 'agent-command-array', + type: 'user_text', + content: '/agent Plan 333', + }, { id: 'transcript-user-1', type: 'user_text', @@ -616,6 +632,33 @@ describe('chatStore history mapping', () => { ]) }) + it('restores user-invoked skill command metadata as readable user history', () => { + const messages: MessageEntry[] = [ + { + id: 'skill-command-user', + type: 'user', + timestamp: '2026-06-26T14:59:44.000Z', + content: [ + 'frontend-design', + '/frontend-design', + 'redesign the settings page', + ].join('\n'), + }, + ] + + const mapped = mapHistoryMessagesToUiMessages(messages) + + expect(mapped).toMatchObject([ + { + id: 'skill-command-user', + type: 'user_text', + content: '/frontend-design redesign the settings page', + transcriptMessageId: 'skill-command-user', + }, + ]) + expect(mapped[0]?.type === 'user_text' ? mapped[0].content : '').not.toContain('') + }) + it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => { const messages: MessageEntry[] = [ { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index d0f5f8db..141f2fac 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2569,14 +2569,74 @@ function extractHistoryTextBlocks(content: unknown): string[] { .filter(Boolean) } -function isInternalCommandBreadcrumbContent(content: unknown): boolean { - const textBlocks = extractHistoryTextBlocks(content) - return textBlocks.length > 0 && textBlocks.every((text) => ( +const COMMAND_METADATA_TAGS = new Set([ + 'command-name', + 'command-message', + 'command-args', + 'local-command-caveat', + 'skill-format', +]) +const COMMAND_METADATA_BLOCK_RE = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gi + +function hasCommandMetadataTag(text: string): boolean { + return ( text.includes('') || text.includes('') || text.includes('') || - text.includes('') + text.includes('') || + text.includes('') + ) +} + +function isOnlyKnownCommandMetadata(text: string): boolean { + const remainder = text.replace(COMMAND_METADATA_BLOCK_RE, (match, tag: string) => ( + COMMAND_METADATA_TAGS.has(tag.toLowerCase()) ? '' : match )) + return remainder.trim().length === 0 +} + +function formatCommandMetadataDisplayText( + commandName: string, + args: string, + skillFormat: boolean, + commandMessage?: string, +): string { + if (skillFormat) { + return `Skill(${commandMessage || commandName.replace(/^\//, '')})` + } + + const normalizedName = commandName.startsWith('/') ? commandName : `/${commandName}` + return [normalizedName, args.trim()].filter(Boolean).join(' ') +} + +function parseCommandMetadataText(text: string): string | null { + const trimmed = text.trim() + if (!hasCommandMetadataTag(trimmed)) return null + if (!isOnlyKnownCommandMetadata(trimmed)) return null + + const commandName = readXmlTag(trimmed, 'command-name') + if (!commandName) return null + + const args = readXmlTag(trimmed, 'command-args') ?? '' + const commandMessage = readXmlTag(trimmed, 'command-message') + const skillFormat = readXmlTag(trimmed, 'skill-format') === 'true' + return formatCommandMetadataDisplayText(commandName, args, skillFormat, commandMessage) +} + +function getCommandMetadataDisplayText(content: unknown): string | null { + const textBlocks = extractHistoryTextBlocks(content) + if (textBlocks.length === 0) return null + + const displayBlocks = textBlocks.map(parseCommandMetadataText) + if (displayBlocks.some((text) => text === null)) return null + return displayBlocks.join('\n') +} + +function shouldHideCommandMetadataContent(content: unknown): boolean { + const textBlocks = extractHistoryTextBlocks(content) + if (textBlocks.length === 0) return false + if (!textBlocks.some(hasCommandMetadataTag)) return false + return getCommandMetadataDisplayText(content) === null } function isTaskNotificationContent(content: unknown): boolean { @@ -3298,8 +3358,22 @@ export function mapHistoryMessagesToUiMessages( suppressTaskNotificationResponse = true continue } - if (msg.type === 'user' && isInternalCommandBreadcrumbContent(msg.content)) { - continue + if (msg.type === 'user') { + const commandDisplayText = getCommandMetadataDisplayText(msg.content) + if (commandDisplayText) { + uiMessages.push({ + id: msg.id || nextId(), + type: 'user_text', + content: commandDisplayText, + ...(msg.id ? { transcriptMessageId: msg.id } : {}), + timestamp: new Date(msg.timestamp).getTime(), + }) + suppressTaskNotificationResponse = false + continue + } + if (shouldHideCommandMetadataContent(msg.content)) { + continue + } } if (msg.type === 'user') { suppressTaskNotificationResponse = false diff --git a/src/server/__tests__/searchService.sessions.test.ts b/src/server/__tests__/searchService.sessions.test.ts index a3356bab..6268a2a4 100644 --- a/src/server/__tests__/searchService.sessions.test.ts +++ b/src/server/__tests__/searchService.sessions.test.ts @@ -163,6 +163,26 @@ describe('SearchService.searchSessions', () => { expect(results).toHaveLength(0) }) + it('indexes readable command metadata entries', async () => { + await writeSessionFile('proj-a', 'session-7', [ + { + type: 'user', + message: { + role: 'user', + content: [ + 'frontend-design', + '/frontend-design', + 'redesign settings page', + ].join('\n'), + }, + }, + ]) + + const { results } = await service.searchSessions('redesign') + expect(results).toHaveLength(1) + expect(results[0]!.matches[0]!.snippet).toContain('/frontend-design redesign settings page') + }) + it('resolves the real session title (custom-title wins) instead of the UUID', async () => { await writeSessionFile('proj-a', 'titled-session', [ { type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } }, diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index c214d5e1..b1c07e70 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -888,7 +888,7 @@ describe('SessionService', () => { ]) }) - it('should hide synthetic interruption, no-response, and command breadcrumb transcript entries', async () => { + it('should hide synthetic interruption, no-response, and malformed command breadcrumb transcript entries', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ makeSnapshotEntry(), @@ -935,19 +935,64 @@ describe('SessionService', () => { uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:05.000Z', }, + { + type: 'user', + message: { + role: 'user', + content: '/agent malformed breadcrumb', + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:06.000Z', + }, makeAssistantEntry('正常助手消息', crypto.randomUUID()), ]) const messages = await service.getSessionMessages(sessionId) - expect(messages).toHaveLength(2) + expect(messages).toHaveLength(4) expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' }) expect(messages[1]).toMatchObject({ + type: 'user', + content: '/exit\nexit\n', + }) + expect(messages[2]).toMatchObject({ + type: 'user', + content: [{ + type: 'text', + text: '/agent\nagent\nPlan 222', + }], + }) + expect(messages[3]).toMatchObject({ type: 'assistant', content: [{ type: 'text', text: '正常助手消息' }], }) }) + it('should keep user-invoked skill command metadata for desktop history restore', async () => { + const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + await writeSessionFile('-tmp-project', sessionId, [ + makeSnapshotEntry(), + makeUserEntry([ + 'frontend-design', + '/frontend-design', + 'redesign the settings page', + ].join('\n'), 'skill-command-user'), + makeAssistantEntry('正常助手消息', 'skill-command-user'), + ]) + + const messages = await service.getSessionMessages(sessionId) + + expect(messages).toHaveLength(2) + const skillCommandContent = String(messages[0]!.content) + expect(messages[0]).toMatchObject({ + id: 'skill-command-user', + type: 'user', + content: expect.stringContaining('/frontend-design'), + }) + expect(skillCommandContent).toContain('redesign the settings page') + expect(messages[1]).toMatchObject({ type: 'assistant' }) + }) + it('should keep /goal local command transcript entries for desktop history restore', async () => { const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' await writeSessionFile('-tmp-project', sessionId, [ diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index 9494334b..a1002621 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -27,7 +27,7 @@ describe('WebSocket memory events', () => { ]) }) - it('does not replay internal slash-command breadcrumbs as user messages', () => { + it('replays slash-command breadcrumbs as readable user messages', () => { expect(translateCliMessage({ type: 'user', isReplay: true, @@ -39,7 +39,9 @@ describe('WebSocket memory events', () => { 'Plan 222', ].join('\n'), }, - }, 'session-1')).toEqual([]) + }, 'session-1')).toEqual([ + { type: 'user_message_replay', content: '/agent Plan 222' }, + ]) expect(translateCliMessage({ type: 'user', @@ -57,6 +59,17 @@ describe('WebSocket memory events', () => { }, ], }, + }, 'session-1')).toEqual([ + { type: 'user_message_replay', content: '/agent Plan 222' }, + ]) + + expect(translateCliMessage({ + type: 'user', + isReplay: true, + message: { + role: 'user', + content: '/agent malformed breadcrumb', + }, }, 'session-1')).toEqual([]) }) diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index 94725f06..390e45b4 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -10,6 +10,10 @@ import * as path from 'path' import * as os from 'os' import { ApiError } from '../middleware/errorHandler.js' import { sessionService } from './sessionService.js' +import { + getCommandMetadataDisplayText, + shouldHideCommandMetadataContent, +} from '../../utils/commandMetadata.js' export type SearchResult = { file: string @@ -390,7 +394,11 @@ export class SearchService { if (entry.type !== 'user' && entry.type !== 'assistant') return [] const content = entry.message?.content - if (this.isInternalCommandBreadcrumb(content)) return [] + const commandDisplayText = getCommandMetadataDisplayText(content) + if (commandDisplayText) { + return [{ role: 'user', text: commandDisplayText }] + } + if (shouldHideCommandMetadataContent(content)) return [] const role: SessionMatchRole = entry.type === 'assistant' || entry.message?.role === 'assistant' @@ -421,19 +429,6 @@ export class SearchService { return out } - private isInternalCommandBreadcrumb(content: unknown): boolean { - const textBlocks = this.extractPlainTextBlocks(content) - return ( - textBlocks.length > 0 && - textBlocks.every((text) => - text.includes('') || - text.includes('') || - text.includes('') || - text.includes(''), - ) - ) - } - /** Window a single match into a one-line, highlighted snippet. */ private buildSnippet( text: string, diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index da9dcbaa..7dea15d4 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -44,6 +44,7 @@ import { roughTokenCountEstimationForMessage, } from '../../services/tokenEstimation.js' import { ProviderService } from './providerService.js' +import { shouldHideCommandMetadataContent } from '../../utils/commandMetadata.js' // ============================================================================ // Types @@ -843,19 +844,6 @@ export class SessionService { .filter(Boolean) } - private isInternalCommandBreadcrumb(content: unknown): boolean { - const textBlocks = this.extractTextBlocks(content) - return ( - textBlocks.length > 0 && - textBlocks.every((text) => - text.includes('') || - text.includes('') || - text.includes('') || - text.includes('') - ) - ) - } - private isSyntheticUserInterruption(content: unknown): boolean { const textBlocks = this.extractTextBlocks(content) return ( @@ -950,7 +938,7 @@ export class SessionService { if (role === 'user') { return ( - this.isInternalCommandBreadcrumb(content) || + shouldHideCommandMetadataContent(content) || this.isSyntheticUserInterruption(content) || this.isTaskNotificationContent(content) ) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index e5774c35..2687afa2 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -29,13 +29,14 @@ import { } from '../services/titleService.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { - COMMAND_ARGS_TAG, - COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, - LOCAL_COMMAND_CAVEAT_TAG, LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDOUT_TAG, } from '../../constants/xml.js' +import { + getCommandMetadataDisplayText, + shouldHideCommandMetadataContent, +} from '../../utils/commandMetadata.js' import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js' import { getDisconnectGraceMs } from './disconnectGraceConfig.js' @@ -2347,34 +2348,12 @@ function hasToolResultBlock(content: unknown): boolean { (block as { type?: unknown }).type === 'tool_result') } -function isInternalCommandBreadcrumb(content: unknown): boolean { - const textBlocks = typeof content === 'string' - ? [content] - : Array.isArray(content) - ? content.flatMap((block) => { - if (!block || typeof block !== 'object') return [] - const typedBlock = block as { type?: unknown; text?: unknown } - return typedBlock.type === 'text' && typeof typedBlock.text === 'string' - ? [typedBlock.text] - : [] - }) - : [] - - return textBlocks.length > 0 && textBlocks.every((text) => { - const trimmed = text.trim() - return ( - trimmed.includes(`<${COMMAND_NAME_TAG}>`) || - trimmed.includes(`<${COMMAND_MESSAGE_TAG}>`) || - trimmed.includes(`<${COMMAND_ARGS_TAG}>`) || - trimmed.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`) - ) - }) -} - function extractReplayUserText(cliMsg: any): string | null { if (cliMsg?.isReplay !== true) return null const content = cliMsg.message?.content - if (isInternalCommandBreadcrumb(content)) return null + const commandDisplayText = getCommandMetadataDisplayText(content) + if (commandDisplayText) return commandDisplayText + if (shouldHideCommandMetadataContent(content)) return null if (isCompactSummaryMessageContent(content)) return null if (hasToolResultBlock(content)) return null if (extractLocalCommandOutput(content)) return null diff --git a/src/utils/commandMetadata.ts b/src/utils/commandMetadata.ts new file mode 100644 index 00000000..37bcd437 --- /dev/null +++ b/src/utils/commandMetadata.ts @@ -0,0 +1,103 @@ +import { + COMMAND_ARGS_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_NAME_TAG, + LOCAL_COMMAND_CAVEAT_TAG, +} from '../constants/xml.js' + +const COMMAND_METADATA_TAGS = new Set([ + COMMAND_NAME_TAG, + COMMAND_MESSAGE_TAG, + COMMAND_ARGS_TAG, + LOCAL_COMMAND_CAVEAT_TAG, + 'skill-format', +]) + +const XML_TAG_BLOCK_PATTERN = /<([a-z][\w-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1>\s*/gi + +function decodeXmlText(text: string): string { + return text + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +function readXmlTag(xml: string, tag: string): string | undefined { + const match = xml.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i')) + const value = match?.[1]?.trim() + return value ? decodeXmlText(value) : undefined +} + +function extractTextBlocks(content: unknown): string[] { + if (typeof content === 'string') return [content] + if (!Array.isArray(content)) return [] + + return content + .flatMap((block) => { + if (!block || typeof block !== 'object') return [] + const record = block as Record + return record.type === 'text' && typeof record.text === 'string' + ? [record.text] + : [] + }) + .map((text) => text.trim()) + .filter(Boolean) +} + +function hasCommandMetadataTag(text: string): boolean { + return ( + text.includes(`<${COMMAND_NAME_TAG}>`) || + text.includes(`<${COMMAND_MESSAGE_TAG}>`) || + text.includes(`<${COMMAND_ARGS_TAG}>`) || + text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`) || + text.includes('') + ) +} + +function isOnlyKnownCommandMetadata(text: string): boolean { + const remainder = text.replace(XML_TAG_BLOCK_PATTERN, (match, tag: string) => ( + COMMAND_METADATA_TAGS.has(tag.toLowerCase()) ? '' : match + )) + return remainder.trim().length === 0 +} + +function formatCommandDisplayText(commandName: string, args: string, skillFormat: boolean, commandMessage?: string): string { + if (skillFormat) { + return `Skill(${commandMessage || commandName.replace(/^\//, '')})` + } + + const normalizedName = commandName.startsWith('/') ? commandName : `/${commandName}` + return [normalizedName, args.trim()].filter(Boolean).join(' ') +} + +function parseCommandMetadataText(text: string): string | null { + const trimmed = text.trim() + if (!hasCommandMetadataTag(trimmed)) return null + if (!isOnlyKnownCommandMetadata(trimmed)) return null + + const commandName = readXmlTag(trimmed, COMMAND_NAME_TAG) + if (!commandName) return null + + const args = readXmlTag(trimmed, COMMAND_ARGS_TAG) ?? '' + const commandMessage = readXmlTag(trimmed, COMMAND_MESSAGE_TAG) + const skillFormat = readXmlTag(trimmed, 'skill-format') === 'true' + return formatCommandDisplayText(commandName, args, skillFormat, commandMessage) +} + +export function getCommandMetadataDisplayText(content: unknown): string | null { + const textBlocks = extractTextBlocks(content) + if (textBlocks.length === 0) return null + + const displayBlocks = textBlocks.map(parseCommandMetadataText) + if (displayBlocks.some((text) => text === null)) return null + return displayBlocks.join('\n') +} + +export function shouldHideCommandMetadataContent(content: unknown): boolean { + const textBlocks = extractTextBlocks(content) + if (textBlocks.length === 0) return false + if (!textBlocks.some(hasCommandMetadataTag)) return false + return getCommandMetadataDisplayText(content) === null +}