Preserve goal command breadcrumbs for history restore

Desktop history can only render the original /goal prompt if the session API returns the raw system local_command transcript entries. The prior fix handled frontend rendering, but the server dropped those entries because they do not carry message.role.

This keeps only /goal local command input and goal-like output records in getSessionMessages while leaving other internal command breadcrumbs filtered.

Constraint: Real CLI transcripts store /goal input/output as system local_command records without message.role.
Rejected: Expose all local_command records | would leak unrelated internal command breadcrumbs such as /exit.
Confidence: high
Scope-risk: narrow
Directive: Keep this allowlist goal-specific unless another command explicitly needs visible history restore.
Tested: bun test src/server/__tests__/sessions.test.ts
Tested: bun test src/commands/goal/goal.test.tsx src/goals/goalState.test.ts src/goals/goalEvaluator.test.ts
Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts src/components/chat/MessageList.test.tsx src/pages/ActiveSession.test.tsx
Tested: cd desktop && bun run lint
Tested: real sessionService.getSessionMessages for 3e9117d5-b792-43c9-bf57-7aec2b124f7e returns initial /goal local_command entries
Not-tested: Packaged app rebuild after this server API fix.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 01:11:21 +08:00
parent 1e0e5bca0f
commit 052cc4da5d
2 changed files with 106 additions and 0 deletions

View File

@ -739,6 +739,61 @@ describe('SessionService', () => {
})
})
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, [
makeSnapshotEntry(),
{
parentUuid: null,
isSidechain: false,
type: 'system',
subtype: 'local_command',
content: '<command-name>/goal</command-name>\n<command-message>goal</command-message>\n<command-args>ship persisted goal</command-args>',
level: 'info',
timestamp: '2026-01-01T00:00:01.000Z',
uuid: 'goal-command',
},
{
parentUuid: 'goal-command',
isSidechain: false,
type: 'system',
subtype: 'local_command',
content: [
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 0 / unlimited tokens',
'Elapsed: 0s',
'Continuations: 0',
'</local-command-stdout>',
].join('\n'),
level: 'info',
timestamp: '2026-01-01T00:00:02.000Z',
uuid: 'goal-output',
},
makeAssistantEntry('正常助手消息', crypto.randomUUID()),
])
const messages = await service.getSessionMessages(sessionId)
expect(messages).toMatchObject([
{
id: 'goal-command',
type: 'system',
content: expect.stringContaining('<command-name>/goal</command-name>'),
},
{
id: 'goal-output',
type: 'system',
content: expect.stringContaining('Goal created.'),
},
{
type: 'assistant',
},
])
})
it('should hide task-notification turns and their automatic responses from history', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
const firstUserId = crypto.randomUUID()

View File

@ -161,6 +161,8 @@ export type TranscriptContextEstimate = {
/** Raw entry parsed from a single JSONL line */
type RawEntry = {
type?: string
subtype?: string
content?: unknown
uuid?: string
messageId?: string
parentUuid?: string | null
@ -556,6 +558,49 @@ export class SessionService {
return false
}
private isGoalLocalCommandOutput(output: string): boolean {
const trimmed = output.trim()
return (
trimmed.startsWith('Goal created.\n') ||
trimmed.startsWith('Goal replaced.\n') ||
trimmed.startsWith('Goal: ') ||
trimmed === 'Goal cleared.' ||
trimmed === 'Goal marked complete.' ||
trimmed === 'No active goal.' ||
trimmed === 'No goal to resume.'
)
}
private isGoalLocalCommandEntry(entry: RawEntry): boolean {
if (
entry.type !== 'system' ||
entry.subtype !== 'local_command' ||
typeof entry.content !== 'string'
) {
return false
}
const commandName = this.readXmlTag(entry.content, 'command-name')?.replace(/^\//, '')
if (commandName) return commandName === 'goal'
const output =
this.readXmlTag(entry.content, 'local-command-stdout') ??
this.readXmlTag(entry.content, 'local-command-stderr')
return output ? this.isGoalLocalCommandOutput(output) : false
}
private goalLocalCommandEntryToMessage(entry: RawEntry): MessageEntry | null {
if (!this.isGoalLocalCommandEntry(entry)) return null
return {
id: entry.uuid || crypto.randomUUID(),
type: 'system',
content: entry.content,
timestamp: entry.timestamp || new Date().toISOString(),
parentUuid: entry.parentUuid ?? undefined,
isSidechain: entry.isSidechain,
}
}
private extractAgentToolUseId(entry: RawEntry): string | undefined {
const content = entry.message?.content
if (!Array.isArray(content)) return undefined
@ -1790,6 +1835,12 @@ export class SessionService {
}
for (const entry of entries) {
const goalLocalCommandMessage = this.goalLocalCommandEntryToMessage(entry)
if (goalLocalCommandMessage) {
messages.push(goalLocalCommandMessage)
continue
}
// Only process transcript entries (user / assistant / system with messages)
if (!entry.message?.role) continue