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
+}