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