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