From 0a7db603949ed4903866460369d36e45881528d2 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: Mon, 15 Jun 2026 23:00:29 +0800 Subject: [PATCH] fix(desktop): hide slash command breadcrumbs on restore (#833) Tested: bun test src/server/__tests__/ws-memory-events.test.ts Tested: bun test src/server/__tests__/sessions.test.ts -t "command breadcrumb" Tested: bun test src/server/__tests__/searchService.sessions.test.ts Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts Tested: bun run check:desktop Tested: bun run check:server Scope-risk: narrow Confidence: high --- desktop/src/stores/chatStore.test.ts | 44 +++++++++++++++++++ desktop/src/stores/chatStore.ts | 13 ++++++ .../__tests__/searchService.sessions.test.ts | 7 +++ src/server/__tests__/sessions.test.ts | 14 ++++++ src/server/__tests__/ws-memory-events.test.ts | 33 ++++++++++++++ src/server/services/searchService.ts | 13 +++--- src/server/services/sessionService.ts | 14 +++--- src/server/ws/handler.ts | 28 ++++++++++++ 8 files changed, 155 insertions(+), 11 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index d00698d1..b075eaa0 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -463,6 +463,50 @@ describe('chatStore history mapping', () => { ]) }) + it('does not restore internal slash-command breadcrumbs as user history bubbles', () => { + const messages: MessageEntry[] = [ + { + id: 'agent-command-string', + type: 'user', + timestamp: '2026-06-15T03:32:13.000Z', + content: [ + 'agent', + '/agent', + 'Plan 222', + ].join('\n'), + }, + { + id: 'agent-command-array', + type: 'user', + timestamp: '2026-06-15T03:32:14.000Z', + content: [ + { + type: 'text', + text: [ + 'agent', + '/agent', + 'Plan 333', + ].join('\n'), + }, + ], + }, + { + id: 'transcript-user-1', + type: 'user', + timestamp: '2026-06-15T03:32:15.000Z', + content: '继续处理这个问题', + }, + ] + + expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'transcript-user-1', + type: 'user_text', + content: '继续处理这个问题', + }, + ]) + }) + 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 e72d9e85..68551830 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -2316,6 +2316,16 @@ function extractHistoryTextBlocks(content: unknown): string[] { .filter(Boolean) } +function isInternalCommandBreadcrumbContent(content: unknown): boolean { + const textBlocks = extractHistoryTextBlocks(content) + return textBlocks.length > 0 && textBlocks.every((text) => ( + text.includes('') || + text.includes('') || + text.includes('') || + text.includes('') + )) +} + function isTaskNotificationContent(content: unknown): boolean { const textBlocks = extractHistoryTextBlocks(content) return textBlocks.length > 0 && textBlocks.every((text) => extractTaskNotificationXml(text) !== null) @@ -3003,6 +3013,9 @@ export function mapHistoryMessagesToUiMessages( suppressTaskNotificationResponse = true continue } + if (msg.type === 'user' && isInternalCommandBreadcrumbContent(msg.content)) { + continue + } if (msg.type === 'user') { suppressTaskNotificationResponse = false } else if (suppressTaskNotificationResponse) { diff --git a/src/server/__tests__/searchService.sessions.test.ts b/src/server/__tests__/searchService.sessions.test.ts index b33b155c..a3356bab 100644 --- a/src/server/__tests__/searchService.sessions.test.ts +++ b/src/server/__tests__/searchService.sessions.test.ts @@ -150,6 +150,13 @@ describe('SearchService.searchSessions', () => { it('skips internal command breadcrumb entries', async () => { await writeSessionFile('proj-a', 'session-6', [ { type: 'user', message: { role: 'user', content: 'deploy magicword' } }, + { + type: 'user', + message: { + role: 'user', + content: [{ type: 'text', text: 'agent magicword' }], + }, + }, ]) const { results } = await service.searchSessions('magicword') diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index af5d4b4e..0aec97cd 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -860,6 +860,20 @@ describe('SessionService', () => { uuid: crypto.randomUUID(), timestamp: '2026-01-01T00:00:04.000Z', }, + { + type: 'user', + message: { + role: 'user', + content: [ + { + type: 'text', + text: '/agent\nagent\nPlan 222', + }, + ], + }, + uuid: crypto.randomUUID(), + timestamp: '2026-01-01T00:00:05.000Z', + }, makeAssistantEntry('正常助手消息', crypto.randomUUID()), ]) diff --git a/src/server/__tests__/ws-memory-events.test.ts b/src/server/__tests__/ws-memory-events.test.ts index dd84bb3e..ed52e52f 100644 --- a/src/server/__tests__/ws-memory-events.test.ts +++ b/src/server/__tests__/ws-memory-events.test.ts @@ -27,6 +27,39 @@ describe('WebSocket memory events', () => { ]) }) + it('does not replay internal slash-command breadcrumbs as user messages', () => { + expect(translateCliMessage({ + type: 'user', + isReplay: true, + message: { + role: 'user', + content: [ + 'agent', + '/agent', + 'Plan 222', + ].join('\n'), + }, + }, 'session-1')).toEqual([]) + + expect(translateCliMessage({ + type: 'user', + isReplay: true, + message: { + role: 'user', + content: [ + { + type: 'text', + text: [ + 'agent', + '/agent', + 'Plan 222', + ].join('\n'), + }, + ], + }, + }, 'session-1')).toEqual([]) + }) + it('forwards CLI memory_saved system messages to the desktop client', () => { const messages = translateCliMessage( { diff --git a/src/server/services/searchService.ts b/src/server/services/searchService.ts index d6be3cb5..94725f06 100644 --- a/src/server/services/searchService.ts +++ b/src/server/services/searchService.ts @@ -422,12 +422,15 @@ export class SearchService { } private isInternalCommandBreadcrumb(content: unknown): boolean { - if (typeof content !== 'string') return false + const textBlocks = this.extractPlainTextBlocks(content) return ( - content.includes('') || - content.includes('') || - content.includes('') || - content.includes('') + textBlocks.length > 0 && + textBlocks.every((text) => + text.includes('') || + text.includes('') || + text.includes('') || + text.includes(''), + ) ) } diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index c4b4c8d1..44799070 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -730,13 +730,15 @@ export class SessionService { } private isInternalCommandBreadcrumb(content: unknown): boolean { - if (typeof content !== 'string') return false - + const textBlocks = this.extractTextBlocks(content) return ( - content.includes('') || - content.includes('') || - content.includes('') || - content.includes('') + textBlocks.length > 0 && + textBlocks.every((text) => + text.includes('') || + text.includes('') || + text.includes('') || + text.includes('') + ) ) } diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index 5879d942..1cd8ec21 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -29,7 +29,10 @@ 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' @@ -2237,9 +2240,34 @@ 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 if (isCompactSummaryMessageContent(content)) return null if (hasToolResultBlock(content)) return null if (extractLocalCommandOutput(content)) return null