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[] = [
{
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',
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: [
'<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', () => {
const messages: MessageEntry[] = [
{

View File

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

View File

@ -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: [
'<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 () => {
await writeSessionFile('proj-a', 'titled-session', [
{ 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'
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: '<command-name>/agent</command-name> 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: '<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',
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 () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
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({
type: 'user',
isReplay: true,
@ -39,7 +39,9 @@ describe('WebSocket memory events', () => {
'<command-args>Plan 222</command-args>',
].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: '<command-name>/agent</command-name> malformed breadcrumb',
},
}, 'session-1')).toEqual([])
})

View File

@ -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('<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. */
private buildSnippet(
text: string,

View File

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

View File

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

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
}