mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-26 15:03:34 +08:00
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:
parent
1e0e5bca0f
commit
052cc4da5d
@ -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 () => {
|
it('should hide task-notification turns and their automatic responses from history', async () => {
|
||||||
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'
|
||||||
const firstUserId = crypto.randomUUID()
|
const firstUserId = crypto.randomUUID()
|
||||||
|
|||||||
@ -161,6 +161,8 @@ export type TranscriptContextEstimate = {
|
|||||||
/** Raw entry parsed from a single JSONL line */
|
/** Raw entry parsed from a single JSONL line */
|
||||||
type RawEntry = {
|
type RawEntry = {
|
||||||
type?: string
|
type?: string
|
||||||
|
subtype?: string
|
||||||
|
content?: unknown
|
||||||
uuid?: string
|
uuid?: string
|
||||||
messageId?: string
|
messageId?: string
|
||||||
parentUuid?: string | null
|
parentUuid?: string | null
|
||||||
@ -556,6 +558,49 @@ export class SessionService {
|
|||||||
return false
|
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 {
|
private extractAgentToolUseId(entry: RawEntry): string | undefined {
|
||||||
const content = entry.message?.content
|
const content = entry.message?.content
|
||||||
if (!Array.isArray(content)) return undefined
|
if (!Array.isArray(content)) return undefined
|
||||||
@ -1790,6 +1835,12 @@ export class SessionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
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)
|
// Only process transcript entries (user / assistant / system with messages)
|
||||||
if (!entry.message?.role) continue
|
if (!entry.message?.role) continue
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user