From 1e0e5bca0fa7cadb32e1841faad5c4db86b4bc50 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 00:43:32 +0800 Subject: [PATCH] Restore completed goals from resumed transcripts Resumed desktop sessions keep the /goal lifecycle in transcript files, but the CLI status command only checked the in-memory goal map. The desktop history mapper also treated the local command breadcrumb as internal state, so the original /goal prompt disappeared when reopening a session. This hydrates /goal command state from the current transcript before lifecycle operations and renders historical /goal command breadcrumbs as visible user messages. Query-only negative status output is kept informational so an old broken "No active goal." response cannot erase an earlier completed goal. Constraint: Goal state is process memory at runtime, while session resume relies on persisted JSONL transcript records. Rejected: Persist a second goal database | the transcript already contains the authoritative command lifecycle and avoids a new storage migration. Confidence: high Scope-risk: narrow Directive: Treat /goal status output as a query result, not as lifecycle mutation; only explicit clear output should remove restored state. 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: bun run check:desktop Tested: real transcript hydrate for 3e9117d5-b792-43c9-bf57-7aec2b124f7e returned Goal: complete Tested: bun test src/server/__tests__/h5-access-auth.test.ts -t 'blocks remote browser SDK requests even under explicit server auth' Not-tested: Full check:server completed 726/727 tests; one unrelated H5 auth hook timed out in the full suite and passed on isolated rerun. --- desktop/src/stores/chatStore.test.ts | 38 +++++++++++++++++++++ desktop/src/stores/chatStore.ts | 16 ++++++++- src/commands/goal/goal.test.tsx | 51 ++++++++++++++++++++++++++-- src/commands/goal/goal.tsx | 12 +++++-- src/goals/goalState.ts | 7 ++-- 5 files changed, 114 insertions(+), 10 deletions(-) diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index d6128bcb..c0ad9a48 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -276,6 +276,11 @@ describe('chatStore history mapping', () => { ] expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'goal-command', + type: 'user_text', + content: '/goal --tokens 2k ship the smoke test', + }, { id: 'goal-output', type: 'goal_event', @@ -314,6 +319,11 @@ describe('chatStore history mapping', () => { ] expect(mapHistoryMessagesToUiMessages(messages)).toMatchObject([ + { + id: 'goal-command', + type: 'user_text', + content: '/goal ship the replacement target', + }, { id: 'goal-output', type: 'goal_event', @@ -355,6 +365,18 @@ describe('chatStore history mapping', () => { timestamp: '2026-04-06T00:00:02.000Z', content: 'Goal marked complete.', }, + { + id: 'goal-status-command', + type: 'system', + timestamp: '2026-04-06T00:00:03.000Z', + content: '/goal\nstatus', + }, + { + id: 'goal-stale-empty-output', + type: 'system', + timestamp: '2026-04-06T00:00:04.000Z', + content: 'No active goal.', + }, ], }) @@ -367,6 +389,11 @@ describe('chatStore history mapping', () => { await useChatStore.getState().loadHistory(TEST_SESSION_ID) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.messages).toMatchObject([ + { + id: 'goal-command', + type: 'user_text', + content: '/goal ship the smoke test', + }, { id: 'goal-output', type: 'goal_event', @@ -379,6 +406,17 @@ describe('chatStore history mapping', () => { action: 'completed', message: 'Goal marked complete.', }, + { + id: 'goal-status-command', + type: 'user_text', + content: '/goal status', + }, + { + id: 'goal-stale-empty-output', + type: 'goal_event', + action: 'message', + message: 'No active goal.', + }, ]) expect(useChatStore.getState().sessions[TEST_SESSION_ID]?.activeGoal).toMatchObject({ action: 'completed', diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index 0d4560f8..596f8d19 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -1149,7 +1149,7 @@ function applyGoalEventToActiveGoal( event.message && /no (active )?goal/i.test(event.message) ) { - return null + return current } if (event.action === 'message') return current const baseGoal = event.action === 'created' || event.action === 'replaced' ? null : current @@ -1189,6 +1189,12 @@ function parseGoalCommandFromLocalCommand(content: unknown): { name: string; arg } } +function formatVisibleLocalCommand(command: { name: string; args: string }): string { + const normalizedName = command.name.replace(/^\//, '') + const args = command.args.trim() + return `/${normalizedName}${args ? ` ${args}` : ''}` +} + function extractLocalCommandOutputText(content: unknown): string | null { const text = extractLocalCommandText(content) if (!text) return null @@ -1501,6 +1507,14 @@ export function mapHistoryMessagesToUiMessages( const localCommand = parseGoalCommandFromLocalCommand(msg.content) if (localCommand) { pendingGoalCommand = localCommand + if (localCommand.name === 'goal') { + uiMessages.push({ + id: msg.id || nextId(), + type: 'user_text', + content: formatVisibleLocalCommand(localCommand), + timestamp, + }) + } continue } diff --git a/src/commands/goal/goal.test.tsx b/src/commands/goal/goal.test.tsx index db7ef697..c584ae27 100644 --- a/src/commands/goal/goal.test.tsx +++ b/src/commands/goal/goal.test.tsx @@ -1,9 +1,11 @@ import { describe, expect, test } from 'bun:test' import { switchSession } from '../../bootstrap/state.js' import type { SessionId } from '../../types/ids.js' +import type { LocalJSXCommandContext } from '../../types/command.js' +import { createCommandInputMessage } from '../../utils/messages.js' import { call } from './goal.js' -async function runGoal(args: string) { +async function runGoal(args: string, context: Partial = {}) { const calls: Array<{ result?: string options?: { @@ -17,7 +19,10 @@ async function runGoal(args: string) { (result, options) => { calls.push({ result, options }) }, - {} as never, + { + messages: [], + ...context, + } as LocalJSXCommandContext, args, ) @@ -107,4 +112,46 @@ describe('/goal command', () => { }) expect(result.options?.shouldQuery).toBeUndefined() }) + + test('hydrates completed goal state from persisted slash command history', async () => { + switchSession(`goal-command-${crypto.randomUUID()}` as SessionId) + + const result = await runGoal('status', { + messages: [ + createCommandInputMessage([ + '/goal', + 'ship persisted goal', + ].join('\n')), + createCommandInputMessage([ + '', + 'Goal created.', + 'Goal: active', + 'Objective: ship persisted goal', + 'Budget: 42 / 2,000 tokens', + 'Elapsed: 1m', + 'Continuations: 3', + '', + ].join('\n')), + createCommandInputMessage([ + '', + 'Goal: complete', + 'Objective: ship persisted goal', + 'Budget: 1,234 / 2,000 tokens', + 'Elapsed: 23m', + 'Continuations: 2', + '', + ].join('\n')), + createCommandInputMessage([ + '/goal', + 'status', + ].join('\n')), + createCommandInputMessage('No active goal.'), + ], + }) + + expect(result.result).toContain('Goal: complete') + expect(result.result).toContain('Objective: ship persisted goal') + expect(result.result).toContain('Budget: 1,234 / 2,000 tokens') + expect(result.result).toContain('Continuations: 2') + }) }) diff --git a/src/commands/goal/goal.tsx b/src/commands/goal/goal.tsx index 29acce87..82a52cdc 100644 --- a/src/commands/goal/goal.tsx +++ b/src/commands/goal/goal.tsx @@ -7,6 +7,7 @@ import { clearThreadGoal, formatGoalStatus, getThreadGoal, + hydrateThreadGoalFromMessages, parseGoalCommand, setThreadGoal, updateThreadGoalStatus, @@ -18,18 +19,23 @@ export async function call( args: string, ): Promise { const threadId = getSessionId() + const getCurrentGoal = () => + getThreadGoal(threadId) ?? hydrateThreadGoalFromMessages(threadId, _context.messages) + try { const parsed = parseGoalCommand(args) if (parsed.type === 'status') { - onDone(formatGoalStatus(getThreadGoal(threadId)), { display: 'system' }) + onDone(formatGoalStatus(getCurrentGoal()), { display: 'system' }) return null } if (parsed.type === 'clear') { + getCurrentGoal() const cleared = clearThreadGoal(threadId) onDone(cleared ? 'Goal cleared.' : 'No active goal.', { display: 'system' }) return null } if (parsed.type === 'pause') { + getCurrentGoal() const goal = updateThreadGoalStatus(threadId, 'paused') onDone(goal ? formatGoalStatus(goal) : 'No active goal.', { display: 'system', @@ -37,6 +43,7 @@ export async function call( return null } if (parsed.type === 'resume') { + getCurrentGoal() const goal = updateThreadGoalStatus(threadId, 'active') onDone(goal ? formatGoalStatus(goal) : 'No goal to resume.', { display: 'system', @@ -46,6 +53,7 @@ export async function call( return null } if (parsed.type === 'complete') { + getCurrentGoal() const goal = updateThreadGoalStatus(threadId, 'complete') onDone(goal ? 'Goal marked complete.' : 'No active goal.', { display: 'system', @@ -53,7 +61,7 @@ export async function call( return null } - const replaced = Boolean(getThreadGoal(threadId)) + const replaced = Boolean(getCurrentGoal()) const goal = setThreadGoal(threadId, { objective: parsed.objective, tokenBudget: parsed.tokenBudget, diff --git a/src/goals/goalState.ts b/src/goals/goalState.ts index 7b7ddb76..e54dcd67 100644 --- a/src/goals/goalState.ts +++ b/src/goals/goalState.ts @@ -257,13 +257,10 @@ function goalFromLocalCommandOutput( now: number, ): ThreadGoal | null { const trimmed = output.trim() - if ( - trimmed === 'Goal cleared.' || - trimmed === 'No active goal.' || - trimmed === 'No goal to resume.' - ) { + if (trimmed === 'Goal cleared.') { return null } + if (trimmed === 'No active goal.' || trimmed === 'No goal to resume.') return current if (trimmed === 'Goal marked complete.') { return current ? { ...current, status: 'complete', updatedAt: now } : null }