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.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-16 00:43:32 +08:00
parent e77dd6d3cb
commit 1e0e5bca0f
5 changed files with 114 additions and 10 deletions

View File

@ -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: '<local-command-stdout>Goal marked complete.</local-command-stdout>',
},
{
id: 'goal-status-command',
type: 'system',
timestamp: '2026-04-06T00:00:03.000Z',
content: '<command-name>/goal</command-name>\n<command-args>status</command-args>',
},
{
id: 'goal-stale-empty-output',
type: 'system',
timestamp: '2026-04-06T00:00:04.000Z',
content: '<local-command-stdout>No active goal.</local-command-stdout>',
},
],
})
@ -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',

View File

@ -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
}

View File

@ -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<LocalJSXCommandContext> = {}) {
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([
'<command-name>/goal</command-name>',
'<command-args>ship persisted goal</command-args>',
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal created.',
'Goal: active',
'Objective: ship persisted goal',
'Budget: 42 / 2,000 tokens',
'Elapsed: 1m',
'Continuations: 3',
'</local-command-stdout>',
].join('\n')),
createCommandInputMessage([
'<local-command-stdout>',
'Goal: complete',
'Objective: ship persisted goal',
'Budget: 1,234 / 2,000 tokens',
'Elapsed: 23m',
'Continuations: 2',
'</local-command-stdout>',
].join('\n')),
createCommandInputMessage([
'<command-name>/goal</command-name>',
'<command-args>status</command-args>',
].join('\n')),
createCommandInputMessage('<local-command-stdout>No active goal.</local-command-stdout>'),
],
})
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')
})
})

View File

@ -7,6 +7,7 @@ import {
clearThreadGoal,
formatGoalStatus,
getThreadGoal,
hydrateThreadGoalFromMessages,
parseGoalCommand,
setThreadGoal,
updateThreadGoalStatus,
@ -18,18 +19,23 @@ export async function call(
args: string,
): Promise<React.ReactNode> {
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,

View File

@ -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
}