From 052cc4da5da3cf520d8bcd4e3d38a364ff48adf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 16 May 2026 01:11:21 +0800 Subject: [PATCH] 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. --- src/server/__tests__/sessions.test.ts | 55 +++++++++++++++++++++++++++ src/server/services/sessionService.ts | 51 +++++++++++++++++++++++++ 2 files changed, 106 insertions(+) diff --git a/src/server/__tests__/sessions.test.ts b/src/server/__tests__/sessions.test.ts index 8300c88b..18c6116a 100644 --- a/src/server/__tests__/sessions.test.ts +++ b/src/server/__tests__/sessions.test.ts @@ -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: '/goal\ngoal\nship persisted goal', + level: 'info', + timestamp: '2026-01-01T00:00:01.000Z', + uuid: 'goal-command', + }, + { + parentUuid: 'goal-command', + isSidechain: false, + type: 'system', + subtype: 'local_command', + content: [ + '', + 'Goal created.', + 'Goal: active', + 'Objective: ship persisted goal', + 'Budget: 0 / unlimited tokens', + 'Elapsed: 0s', + 'Continuations: 0', + '', + ].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('/goal'), + }, + { + 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() diff --git a/src/server/services/sessionService.ts b/src/server/services/sessionService.ts index 9e28dfdf..cc6196d7 100644 --- a/src/server/services/sessionService.ts +++ b/src/server/services/sessionService.ts @@ -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