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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-15 23:00:29 +08:00
parent 8d26c2c154
commit 0a7db60394
8 changed files with 155 additions and 11 deletions

View File

@ -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: [
'<command-message>agent</command-message>',
'<command-name>/agent</command-name>',
'<command-args>Plan 222</command-args>',
].join('\n'),
},
{
id: 'agent-command-array',
type: 'user',
timestamp: '2026-06-15T03:32:14.000Z',
content: [
{
type: 'text',
text: [
'<command-message>agent</command-message>',
'<command-name>/agent</command-name>',
'<command-args>Plan 333</command-args>',
].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[] = [
{

View File

@ -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('<command-name>') ||
text.includes('<command-message>') ||
text.includes('<command-args>') ||
text.includes('<local-command-caveat>')
))
}
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) {

View File

@ -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: '<command-name>deploy</command-name> magicword' } },
{
type: 'user',
message: {
role: 'user',
content: [{ type: 'text', text: '<command-message>agent</command-message> magicword' }],
},
},
])
const { results } = await service.searchSessions('magicword')

View File

@ -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: '<command-name>/agent</command-name>\n<command-message>agent</command-message>\n<command-args>Plan 222</command-args>',
},
],
},
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:05.000Z',
},
makeAssistantEntry('正常助手消息', crypto.randomUUID()),
])

View File

@ -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: [
'<command-message>agent</command-message>',
'<command-name>/agent</command-name>',
'<command-args>Plan 222</command-args>',
].join('\n'),
},
}, 'session-1')).toEqual([])
expect(translateCliMessage({
type: 'user',
isReplay: true,
message: {
role: 'user',
content: [
{
type: 'text',
text: [
'<command-message>agent</command-message>',
'<command-name>/agent</command-name>',
'<command-args>Plan 222</command-args>',
].join('\n'),
},
],
},
}, 'session-1')).toEqual([])
})
it('forwards CLI memory_saved system messages to the desktop client', () => {
const messages = translateCliMessage(
{

View File

@ -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('<command-name>') ||
content.includes('<command-message>') ||
content.includes('<command-args>') ||
content.includes('<local-command-caveat>')
textBlocks.length > 0 &&
textBlocks.every((text) =>
text.includes('<command-name>') ||
text.includes('<command-message>') ||
text.includes('<command-args>') ||
text.includes('<local-command-caveat>'),
)
)
}

View File

@ -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('<command-name>') ||
content.includes('<command-message>') ||
content.includes('<command-args>') ||
content.includes('<local-command-caveat>')
textBlocks.length > 0 &&
textBlocks.every((text) =>
text.includes('<command-name>') ||
text.includes('<command-message>') ||
text.includes('<command-args>') ||
text.includes('<local-command-caveat>')
)
)
}

View File

@ -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