mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
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:
parent
547f95a599
commit
c6be08e471
@ -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[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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' } },
|
||||||
|
|||||||
@ -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, [
|
||||||
|
|||||||
@ -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([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
103
src/utils/commandMetadata.ts
Normal file
103
src/utils/commandMetadata.ts
Normal 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(/</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<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
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user