mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
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:
parent
e77dd6d3cb
commit
1e0e5bca0f
@ -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',
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user