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
This commit is contained in:
程序员阿江(Relakkes) 2026-07-02 21:00:27 +08:00
parent 547f95a599
commit c6be08e471
9 changed files with 327 additions and 67 deletions

View File

@ -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[] = [ const messages: MessageEntry[] = [
{ {
id: 'agent-command-string', 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: '<command-name>/agent</command-name> malformed breadcrumb',
},
{ {
id: 'transcript-user-1', id: 'transcript-user-1',
type: 'user', type: 'user',
@ -608,6 +614,16 @@ describe('chatStore history mapping', () => {
] ]
expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ 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', id: 'transcript-user-1',
type: 'user_text', 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: [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>redesign the settings page</command-args>',
].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('<command-message>')
})
it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => { it('restores persisted image user messages as renderable attachments without exposing image metadata text', () => {
const messages: MessageEntry[] = [ const messages: MessageEntry[] = [
{ {

View File

@ -2569,14 +2569,74 @@ function extractHistoryTextBlocks(content: unknown): string[] {
.filter(Boolean) .filter(Boolean)
} }
function isInternalCommandBreadcrumbContent(content: unknown): boolean { const COMMAND_METADATA_TAGS = new Set([
const textBlocks = extractHistoryTextBlocks(content) 'command-name',
return textBlocks.length > 0 && textBlocks.every((text) => ( '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('<command-name>') || text.includes('<command-name>') ||
text.includes('<command-message>') || text.includes('<command-message>') ||
text.includes('<command-args>') || text.includes('<command-args>') ||
text.includes('<local-command-caveat>') text.includes('<local-command-caveat>') ||
text.includes('<skill-format>')
)
}
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 { function isTaskNotificationContent(content: unknown): boolean {
@ -3298,8 +3358,22 @@ export function mapHistoryMessagesToUiMessages(
suppressTaskNotificationResponse = true suppressTaskNotificationResponse = true
continue continue
} }
if (msg.type === 'user' && isInternalCommandBreadcrumbContent(msg.content)) { if (msg.type === 'user') {
continue 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') { if (msg.type === 'user') {
suppressTaskNotificationResponse = false suppressTaskNotificationResponse = false

View File

@ -163,6 +163,26 @@ describe('SearchService.searchSessions', () => {
expect(results).toHaveLength(0) expect(results).toHaveLength(0)
}) })
it('indexes readable command metadata entries', async () => {
await writeSessionFile('proj-a', 'session-7', [
{
type: 'user',
message: {
role: 'user',
content: [
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>redesign settings page</command-args>',
].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 () => { it('resolves the real session title (custom-title wins) instead of the UUID', async () => {
await writeSessionFile('proj-a', 'titled-session', [ await writeSessionFile('proj-a', 'titled-session', [
{ type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } }, { type: 'user', uuid: 'u1', message: { role: 'user', content: 'discuss searchword topic' } },

View File

@ -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' const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [ await writeSessionFile('-tmp-project', sessionId, [
makeSnapshotEntry(), makeSnapshotEntry(),
@ -935,19 +935,64 @@ describe('SessionService', () => {
uuid: crypto.randomUUID(), uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:05.000Z', timestamp: '2026-01-01T00:00:05.000Z',
}, },
{
type: 'user',
message: {
role: 'user',
content: '<command-name>/agent</command-name> malformed breadcrumb',
},
uuid: crypto.randomUUID(),
timestamp: '2026-01-01T00:00:06.000Z',
},
makeAssistantEntry('正常助手消息', crypto.randomUUID()), makeAssistantEntry('正常助手消息', crypto.randomUUID()),
]) ])
const messages = await service.getSessionMessages(sessionId) const messages = await service.getSessionMessages(sessionId)
expect(messages).toHaveLength(2) expect(messages).toHaveLength(4)
expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' }) expect(messages[0]).toMatchObject({ type: 'user', content: '正常用户消息' })
expect(messages[1]).toMatchObject({ expect(messages[1]).toMatchObject({
type: 'user',
content: '<command-name>/exit</command-name>\n<command-message>exit</command-message>\n<command-args></command-args>',
})
expect(messages[2]).toMatchObject({
type: 'user',
content: [{
type: 'text',
text: '<command-name>/agent</command-name>\n<command-message>agent</command-message>\n<command-args>Plan 222</command-args>',
}],
})
expect(messages[3]).toMatchObject({
type: 'assistant', type: 'assistant',
content: [{ type: 'text', text: '正常助手消息' }], 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([
'<command-message>frontend-design</command-message>',
'<command-name>/frontend-design</command-name>',
'<command-args>redesign the settings page</command-args>',
].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('<command-name>/frontend-design</command-name>'),
})
expect(skillCommandContent).toContain('<command-args>redesign the settings page</command-args>')
expect(messages[1]).toMatchObject({ type: 'assistant' })
})
it('should keep /goal local command transcript entries for desktop history restore', async () => { it('should keep /goal local command transcript entries for desktop history restore', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
await writeSessionFile('-tmp-project', sessionId, [ await writeSessionFile('-tmp-project', sessionId, [

View File

@ -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({ expect(translateCliMessage({
type: 'user', type: 'user',
isReplay: true, isReplay: true,
@ -39,7 +39,9 @@ describe('WebSocket memory events', () => {
'<command-args>Plan 222</command-args>', '<command-args>Plan 222</command-args>',
].join('\n'), ].join('\n'),
}, },
}, 'session-1')).toEqual([]) }, 'session-1')).toEqual([
{ type: 'user_message_replay', content: '/agent Plan 222' },
])
expect(translateCliMessage({ expect(translateCliMessage({
type: 'user', 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: '<command-name>/agent</command-name> malformed breadcrumb',
},
}, 'session-1')).toEqual([]) }, 'session-1')).toEqual([])
}) })

View File

@ -10,6 +10,10 @@ import * as path from 'path'
import * as os from 'os' import * as os from 'os'
import { ApiError } from '../middleware/errorHandler.js' import { ApiError } from '../middleware/errorHandler.js'
import { sessionService } from './sessionService.js' import { sessionService } from './sessionService.js'
import {
getCommandMetadataDisplayText,
shouldHideCommandMetadataContent,
} from '../../utils/commandMetadata.js'
export type SearchResult = { export type SearchResult = {
file: string file: string
@ -390,7 +394,11 @@ export class SearchService {
if (entry.type !== 'user' && entry.type !== 'assistant') return [] if (entry.type !== 'user' && entry.type !== 'assistant') return []
const content = entry.message?.content 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 = const role: SessionMatchRole =
entry.type === 'assistant' || entry.message?.role === 'assistant' entry.type === 'assistant' || entry.message?.role === 'assistant'
@ -421,19 +429,6 @@ export class SearchService {
return out return out
} }
private isInternalCommandBreadcrumb(content: unknown): boolean {
const textBlocks = this.extractPlainTextBlocks(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>'),
)
)
}
/** Window a single match into a one-line, highlighted snippet. */ /** Window a single match into a one-line, highlighted snippet. */
private buildSnippet( private buildSnippet(
text: string, text: string,

View File

@ -44,6 +44,7 @@ import {
roughTokenCountEstimationForMessage, roughTokenCountEstimationForMessage,
} from '../../services/tokenEstimation.js' } from '../../services/tokenEstimation.js'
import { ProviderService } from './providerService.js' import { ProviderService } from './providerService.js'
import { shouldHideCommandMetadataContent } from '../../utils/commandMetadata.js'
// ============================================================================ // ============================================================================
// Types // Types
@ -843,19 +844,6 @@ export class SessionService {
.filter(Boolean) .filter(Boolean)
} }
private isInternalCommandBreadcrumb(content: unknown): boolean {
const textBlocks = this.extractTextBlocks(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>')
)
)
}
private isSyntheticUserInterruption(content: unknown): boolean { private isSyntheticUserInterruption(content: unknown): boolean {
const textBlocks = this.extractTextBlocks(content) const textBlocks = this.extractTextBlocks(content)
return ( return (
@ -950,7 +938,7 @@ export class SessionService {
if (role === 'user') { if (role === 'user') {
return ( return (
this.isInternalCommandBreadcrumb(content) || shouldHideCommandMetadataContent(content) ||
this.isSyntheticUserInterruption(content) || this.isSyntheticUserInterruption(content) ||
this.isTaskNotificationContent(content) this.isTaskNotificationContent(content)
) )

View File

@ -29,13 +29,14 @@ import {
} from '../services/titleService.js' } from '../services/titleService.js'
import { parseSlashCommand } from '../../utils/slashCommandParsing.js' import { parseSlashCommand } from '../../utils/slashCommandParsing.js'
import { import {
COMMAND_ARGS_TAG,
COMMAND_MESSAGE_TAG,
COMMAND_NAME_TAG, COMMAND_NAME_TAG,
LOCAL_COMMAND_CAVEAT_TAG,
LOCAL_COMMAND_STDERR_TAG, LOCAL_COMMAND_STDERR_TAG,
LOCAL_COMMAND_STDOUT_TAG, LOCAL_COMMAND_STDOUT_TAG,
} from '../../constants/xml.js' } from '../../constants/xml.js'
import {
getCommandMetadataDisplayText,
shouldHideCommandMetadataContent,
} from '../../utils/commandMetadata.js'
import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js' import { shouldCreateWorktreeForSessionLaunch } from '../services/repositoryLaunchService.js'
import { getDisconnectGraceMs } from './disconnectGraceConfig.js' import { getDisconnectGraceMs } from './disconnectGraceConfig.js'
@ -2347,34 +2348,12 @@ function hasToolResultBlock(content: unknown): boolean {
(block as { type?: unknown }).type === 'tool_result') (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 { function extractReplayUserText(cliMsg: any): string | null {
if (cliMsg?.isReplay !== true) return null if (cliMsg?.isReplay !== true) return null
const content = cliMsg.message?.content 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 (isCompactSummaryMessageContent(content)) return null
if (hasToolResultBlock(content)) return null if (hasToolResultBlock(content)) return null
if (extractLocalCommandOutput(content)) return null if (extractLocalCommandOutput(content)) return null

View File

@ -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(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;/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<string, unknown>
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('<skill-format>')
)
}
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
}