diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index 3659abcbd..1d0d522bd 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -32,9 +32,10 @@ import { import type { CanUseToolFn } from './hooks/useCanUseTool.js' import { loadMemoryPrompt } from './memdir/memdir.js' import { hasAutoMemPathOverride } from './memdir/paths.js' -import { query } from './query.js' +import { query as defaultQuery } from './query.js' import { categorizeRetryableAPIError } from './services/api/errors.js' import type { AutoCompactTrackingState } from './services/compact/autoCompact.js' +import { toSDKGoalStatusMessage } from './services/goal/sdk.js' import type { MCPServerConnection } from './services/mcp/types.js' import type { AppState } from './state/AppState.js' import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js' @@ -170,6 +171,7 @@ export type QueryEngineConfig = { yieldedSystemMsg: Message, store: Message[], ) => { messages: Message[]; executed: boolean } | undefined + query?: typeof defaultQuery } /** @@ -680,7 +682,8 @@ export class QueryEngine { ? countToolCalls(this.mutableMessages, SYNTHETIC_OUTPUT_TOOL_NAME) : 0 - for await (const message of query({ + const runQuery = this.config.query ?? defaultQuery + for await (const message of runQuery({ messages, systemPrompt, userContext, @@ -991,6 +994,8 @@ export class QueryEngine { uuid: message.uuid, } } + const goalStatusMessage = toSDKGoalStatusMessage(message) + if (goalStatusMessage) yield goalStatusMessage // Don't yield other system messages in headless mode break } diff --git a/src/commands.ts b/src/commands.ts index d1aeb71ba..79ac1a1d9 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -4,6 +4,7 @@ import autofixPr from './commands/autofix-pr/index.js' import backfillSessions from './commands/backfill-sessions/index.js' import btw from './commands/btw/index.js' import goodClaude from './commands/good-claude/index.js' +import goal from './commands/goal/index.js' import issue from './commands/issue/index.js' import feedback from './commands/feedback/index.js' import clear from './commands/clear/index.js' @@ -325,6 +326,7 @@ const COMMANDS = memoize((): Command[] => [ theme, logo, feedback, + goal, review, ultrareview, rewind, @@ -650,6 +652,7 @@ export const REMOTE_SAFE_COMMANDS: Set = new Set([ copy, // Copy last message btw, // Quick note feedback, // Send feedback + goal, // Manage session goal continuation plan, // Plan mode toggle keybindings, // Keybinding management statusline, // Status line toggle @@ -677,6 +680,7 @@ export const BRIDGE_SAFE_COMMANDS: Set = new Set( summary, // Summarize conversation releaseNotes, // Show changelog files, // List tracked files + goal, // Manage session goal continuation ].filter((c): c is Command => c !== null), ) diff --git a/src/commands/clear/conversation.goal.test.ts b/src/commands/clear/conversation.goal.test.ts new file mode 100644 index 000000000..ced934c25 --- /dev/null +++ b/src/commands/clear/conversation.goal.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from 'bun:test' + +import { + getSessionId, + getSessionProjectDir, + switchSession, +} from '../../bootstrap/state.js' +import { createGoalState } from '../../services/goal/state.js' +import { getDefaultAppState, type AppState } from '../../state/AppStateStore.js' +import { clearConversation } from './conversation.js' + +describe('/clear goal lifecycle', () => { + test('/clear clears active goal state', async () => { + const previousBareMode = process.env.CLAUDE_CODE_SIMPLE + const previousSessionId = getSessionId() + const previousSessionProjectDir = getSessionProjectDir() + process.env.CLAUDE_CODE_SIMPLE = '1' + let state: AppState = { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + } + let messages: any[] = [{ type: 'user', uuid: 'user-1' }] + let sawEmptyMessages = false + + try { + await clearConversation({ + setMessages: updater => { + messages = updater(messages) + if (messages.length === 0) sawEmptyMessages = true + }, + readFileState: new Map() as any, + getAppState: () => state, + setAppState: updater => { + state = updater(state) + }, + }) + } finally { + if (previousBareMode === undefined) { + delete process.env.CLAUDE_CODE_SIMPLE + } else { + process.env.CLAUDE_CODE_SIMPLE = previousBareMode + } + switchSession(previousSessionId, previousSessionProjectDir) + } + + expect(state.goal).toBeNull() + expect(sawEmptyMessages).toBe(true) + }) +}) diff --git a/src/commands/clear/conversation.ts b/src/commands/clear/conversation.ts index ef89cebbe..87e08ad45 100644 --- a/src/commands/clear/conversation.ts +++ b/src/commands/clear/conversation.ts @@ -37,6 +37,7 @@ import { processSessionStartHooks } from '../../utils/sessionStart.js' import { clearSessionMetadata, getAgentTranscriptPath, + recordGoalState, resetSessionFilePointer, saveWorktreeState, } from '../../utils/sessionStorage.js' @@ -195,10 +196,13 @@ export async function clearConversation({ resources: {}, pluginReconnectKey: prev.mcp.pluginReconnectKey, }, + goal: null, } }) } + await recordGoalState(null) + // Clear plan slug cache so a new plan file is used after /clear clearAllPlanSlugs() diff --git a/src/commands/goal/goal.test.ts b/src/commands/goal/goal.test.ts new file mode 100644 index 000000000..853cebd0c --- /dev/null +++ b/src/commands/goal/goal.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, test } from 'bun:test' + +import { + achieveGoal, + createGoalState, + pauseGoal, +} from '../../services/goal/state.js' +import { getDefaultAppState, type AppState } from '../../state/AppStateStore.js' +import type { LocalCommandResult } from '../../types/command.js' +import { call, createGoalCall } from './goal.js' + +type TextCommandResult = Extract + +function expectTextResult(result: LocalCommandResult): TextCommandResult { + expect(result.type).toBe('text') + return result as TextCommandResult +} + +function makeContext(initialGoal: AppState['goal'] = null) { + let state: AppState = { + ...getDefaultAppState(), + goal: initialGoal, + } + + return { + context: { + getAppState: () => state, + setAppState: (updater: (prev: AppState) => AppState) => { + state = updater(state) + }, + } as any, + getState: () => state, + } +} + +describe('/goal command', () => { + test('/goal shows no goal status', async () => { + const { context } = makeContext() + + const result = expectTextResult(await call('', context)) + + expect(result.value).toContain('No goal set') + }) + + test('/goal status returns current status without mutating goal', async () => { + const { context, getState } = makeContext( + createGoalState('finish implementation'), + ) + const originalGoal = getState().goal + + const result = expectTextResult(await call('status', context)) + + expect(result.value).toContain('Status: active') + expect(result.value).toContain('Condition: finish implementation') + expect(getState().goal).toBe(originalGoal) + expect(result.shouldQuery).toBeUndefined() + expect(result.metaMessages).toBeUndefined() + }) + + test('/goal shows active, paused, and achieved status details', async () => { + const active = createGoalState('finish implementation') + const { context, getState } = makeContext(active) + + const activeResult = expectTextResult(await call('', context)) + expect(activeResult.value).toContain('Status: active') + expect(activeResult.value).toContain('Condition: finish implementation') + expect(activeResult.value).toContain('Turns: 0/50') + expect(activeResult.value).toContain('Evaluator failures: 0') + + context.setAppState(prev => ({ ...prev, goal: pauseGoal(getState().goal!) })) + const pausedResult = expectTextResult(await call('', context)) + expect(pausedResult.value).toContain('Status: paused') + + context.setAppState(prev => ({ + ...prev, + goal: achieveGoal(createGoalState('finish implementation'), { + evaluatedMessageUuid: 'assistant-1', + reason: 'done', + }), + })) + const achievedResult = expectTextResult(await call('', context)) + expect(achievedResult.value).toContain('Status: achieved') + expect(achievedResult.value).toContain('Turns: 1/50') + expect(achievedResult.value).toContain('Last evaluator reason: done') + }) + + test('/goal sets an active goal and starts a turn', async () => { + const { context, getState } = makeContext() + + const result = expectTextResult( + await call('finish the implementation', context), + ) + + expect(getState().goal?.status).toBe('active') + expect(getState().goal?.condition).toBe('finish the implementation') + expect(result.value).toContain('Goal set') + expect(result.shouldQuery).toBe(true) + expect(result.metaMessages?.[0]).toContain('finish the implementation') + }) + + test('/goal validates empty conditions', async () => { + const { context, getState } = makeContext() + + const result = expectTextResult(await call('""', context)) + + expect(getState().goal).toBeNull() + expect(result.value).toContain('Goal condition cannot be empty') + expect(result.shouldQuery).toBeUndefined() + }) + + test('/goal validates conditions over 4,000 characters', async () => { + const { context, getState } = makeContext() + + const result = expectTextResult(await call('x'.repeat(4001), context)) + + expect(getState().goal).toBeNull() + expect(result.value).toContain('4,000 characters') + expect(result.shouldQuery).toBeUndefined() + }) + + test('/goal replaces an active goal cleanly', async () => { + const { context, getState } = makeContext() + + await call('first goal', context) + const firstId = getState().goal?.id + await call('second goal', context) + + expect(getState().goal?.id).not.toBe(firstId) + expect(getState().goal?.condition).toBe('second goal') + expect(getState().goal?.status).toBe('active') + }) + + test('/goal clear and aliases clear active goal', async () => { + const aliases = ['clear', 'stop', 'off', 'reset', 'none', 'cancel'] + + for (const alias of aliases) { + const { context, getState } = makeContext( + createGoalState('finish implementation'), + ) + const result = expectTextResult(await call(alias, context)) + + expect(getState().goal).toBeNull() + expect(result.value).toContain('Goal cleared') + } + }) + + test('/goal pause pauses auto-continuation', async () => { + const { context, getState } = makeContext( + createGoalState('finish implementation'), + ) + + const result = expectTextResult(await call('pause', context)) + + expect(getState().goal?.status).toBe('paused') + expect(result.value).toContain('Goal paused') + }) + + test('/goal resume resumes a paused goal and starts a turn', async () => { + const paused = pauseGoal(createGoalState('finish implementation')) + const { context, getState } = makeContext(paused) + + const result = expectTextResult(await call('resume', context)) + + expect(getState().goal?.status).toBe('active') + expect(result.value).toContain('Goal resumed') + expect(result.shouldQuery).toBe(true) + expect(result.metaMessages?.[0]).toContain('finish implementation') + }) + + test('/goal resume reports when there is no goal to resume', async () => { + const { context, getState } = makeContext() + + const result = expectTextResult(await call('resume', context)) + + expect(getState().goal).toBeNull() + expect(result.value).toBe('No goal to resume.') + expect(result.shouldQuery).toBeUndefined() + expect(result.metaMessages).toBeUndefined() + }) + + test('/goal does not mutate in-memory state when persistence fails', async () => { + const callWithFailingPersistence = createGoalCall(async () => { + throw new Error('persist failed') + }) + const cases = [ + { + action: 'new persisted goal', + initialGoal: createGoalState('existing goal'), + }, + { + action: 'clear', + initialGoal: createGoalState('goal to clear'), + }, + { + action: 'pause', + initialGoal: createGoalState('goal to pause'), + }, + { + action: 'resume', + initialGoal: pauseGoal(createGoalState('goal to resume')), + }, + ] + + for (const { action, initialGoal } of cases) { + const { context, getState } = makeContext(initialGoal) + + await expect(callWithFailingPersistence(action, context)).rejects.toThrow( + 'persist failed', + ) + expect(getState().goal).toBe(initialGoal) + } + }) +}) diff --git a/src/commands/goal/goal.ts b/src/commands/goal/goal.ts new file mode 100644 index 000000000..75b78d869 --- /dev/null +++ b/src/commands/goal/goal.ts @@ -0,0 +1,137 @@ +import type { LocalCommandCall } from '../../types/command.js' +import { buildGoalStartInstruction } from '../../services/goal/instructions.js' +import { saveGoalState } from '../../services/goal/persistence.js' +import { + createGoalState, + pauseGoal, + resumeGoal, + validateGoalCondition, +} from '../../services/goal/state.js' +import type { GoalState } from '../../services/goal/types.js' + +type SaveGoalState = typeof saveGoalState + +const CLEAR_ALIASES = new Set([ + 'clear', + 'stop', + 'off', + 'reset', + 'none', + 'cancel', +]) + +function formatElapsed(fromIso: string, toIso?: string): string { + const from = Date.parse(fromIso) + const to = toIso ? Date.parse(toIso) : Date.now() + if (!Number.isFinite(from) || !Number.isFinite(to)) return 'unknown' + + const seconds = Math.max(0, Math.floor((to - from) / 1000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ${seconds % 60}s` + const hours = Math.floor(minutes / 60) + return `${hours}h ${minutes % 60}m` +} + +function formatStatus(goal: GoalState | null): string { + if (!goal) return 'No goal set.' + + const elapsedEnd = + goal.status === 'achieved' + ? goal.achievedAt + : goal.status === 'paused' + ? goal.pausedAt + : undefined + + return [ + `Status: ${goal.status}`, + `Condition: ${goal.condition}`, + `Turns: ${goal.turnCount}/${goal.maxTurns}`, + `Elapsed: ${formatElapsed(goal.startedAt, elapsedEnd)}`, + `Last evaluator reason: ${goal.lastReason ?? 'none'}`, + `Evaluator failures: ${goal.evaluatorFailures}`, + ].join('\n') +} + +async function setGoal( + condition: string, + context: Parameters[1], + persistGoalState: SaveGoalState, +) { + const goal = createGoalState(condition) + await persistGoalState(goal) + context.setAppState(prev => ({ ...prev, goal })) + return { + type: 'text' as const, + value: `Goal set: ${goal.condition}`, + shouldQuery: true, + metaMessages: [buildGoalStartInstruction(goal)], + } +} + +export function createGoalCall( + persistGoalState: SaveGoalState = saveGoalState, +): LocalCommandCall { + return async (args, context) => { + const raw = args.trim() + const action = raw.toLowerCase() + const currentGoal = context.getAppState().goal ?? null + + if (!raw) { + return { type: 'text', value: formatStatus(currentGoal) } + } + + if (action === 'status') { + return { type: 'text', value: formatStatus(currentGoal) } + } + + if (CLEAR_ALIASES.has(action)) { + await persistGoalState(null) + context.setAppState(prev => ({ ...prev, goal: null })) + return { type: 'text', value: 'Goal cleared.' } + } + + if (action === 'pause') { + if (!currentGoal || currentGoal.status !== 'active') { + return { type: 'text', value: 'No active goal to pause.' } + } + const paused = pauseGoal(currentGoal) + await persistGoalState(paused) + context.setAppState(prev => ({ ...prev, goal: paused })) + return { type: 'text', value: 'Goal paused.' } + } + + if (action === 'resume') { + if (!currentGoal) { + return { type: 'text', value: 'No goal to resume.' } + } + if (currentGoal.status !== 'paused' && currentGoal.status !== 'active') { + return { + type: 'text', + value: `Cannot resume a ${currentGoal.status} goal.`, + } + } + const resumed = resumeGoal(currentGoal) + await persistGoalState(resumed) + context.setAppState(prev => ({ ...prev, goal: resumed })) + return { + type: 'text', + value: + currentGoal.status === 'active' + ? 'Goal already active; continuing.' + : 'Goal resumed.', + shouldQuery: true, + metaMessages: [buildGoalStartInstruction(resumed)], + } + } + + const validated = validateGoalCondition(raw) + if (!validated.ok) { + return { type: 'text', value: validated.error } + } + + return setGoal(validated.condition, context, persistGoalState) + } +} + +export const call: LocalCommandCall = createGoalCall() diff --git a/src/commands/goal/index.ts b/src/commands/goal/index.ts new file mode 100644 index 000000000..431fb15a5 --- /dev/null +++ b/src/commands/goal/index.ts @@ -0,0 +1,12 @@ +import type { Command } from '../../commands.js' + +const goal = { + type: 'local', + name: 'goal', + description: 'Set and manage a session completion goal', + argumentHint: '[condition|status|pause|resume|clear]', + supportsNonInteractive: true, + load: () => import('./goal.js'), +} satisfies Command + +export default goal diff --git a/src/query.ts b/src/query.ts index ae6f0bf9f..f82d586b0 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1630,6 +1630,8 @@ async function* queryLoop( toolUseContext, querySource, stopHookActive, + deps.goalEvaluationDeps, + deps.stopHookExecutionDeps, ) if (stopHookResult.preventContinuation) { @@ -1659,7 +1661,7 @@ async function* queryLoop( maxOutputTokensOverride: undefined, providerMaxOutputTokensCap, pendingToolUseSummary: undefined, - stopHookActive: true, + stopHookActive: stopHookResult.stopHookActive, turnCount, continuationNudgeCount: state.continuationNudgeCount, transition: { reason: 'stop_hook_blocking' }, diff --git a/src/query/deps.ts b/src/query/deps.ts index 713688811..3058e84ea 100644 --- a/src/query/deps.ts +++ b/src/query/deps.ts @@ -2,6 +2,8 @@ import { randomUUID } from 'crypto' import { queryModelWithStreaming } from '../services/api/claude.js' import { autoCompactIfNeeded } from '../services/compact/autoCompact.js' import { microcompactMessages } from '../services/compact/microCompact.js' +import type { GoalEvaluationDeps } from '../services/goal/controller.js' +import type { StopHookExecutionDeps } from './stopHooks.js' // -- deps @@ -28,6 +30,10 @@ export type QueryDeps = { // -- platform uuid: () => string + + // -- goal continuation + goalEvaluationDeps?: GoalEvaluationDeps + stopHookExecutionDeps?: StopHookExecutionDeps } export function productionDeps(): QueryDeps { diff --git a/src/query/goalContinuation.test.ts b/src/query/goalContinuation.test.ts new file mode 100644 index 000000000..bf9001deb --- /dev/null +++ b/src/query/goalContinuation.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, test } from 'bun:test' + +import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' +import { createGoalState } from '../services/goal/state.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' + +function assistant(uuid: string, text: string) { + return { + type: 'assistant', + uuid, + message: { + id: uuid, + type: 'message', + role: 'assistant', + model: 'test-model', + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + content: [{ type: 'text', text }], + }, + } +} + +function makeToolUseContext(appStateRef: { current: AppState }) { + return { + options: { + commands: [], + debug: false, + mainLoopModel: 'sonnet', + tools: [], + verbose: false, + thinkingConfig: { type: 'disabled' }, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: true, + agentDefinitions: { activeAgents: [], allAgents: [] }, + }, + abortController: new AbortController(), + readFileState: new Map(), + getAppState: () => appStateRef.current, + setAppState: (updater: (prev: AppState) => AppState) => { + appStateRef.current = updater(appStateRef.current) + }, + setInProgressToolUseIDs: () => {}, + setResponseLength: () => {}, + updateFileHistoryState: () => {}, + updateAttributionState: () => {}, + messages: [], + } as any +} + +describe('goal query continuation', () => { + test('shared query path continues after incomplete goal and stops when achieved', async () => { + const decisions = [ + { + complete: false, + confidence: 0.7, + decision: 'incomplete' as const, + reason: 'Implementation is not verified.', + nextInstruction: 'Run tests.', + }, + { + complete: true, + confidence: 0.9, + decision: 'complete' as const, + reason: 'Implementation is verified.', + nextInstruction: null, + }, + ] + const { query } = await import('../query.js') + let modelCalls = 0 + const modelRequestMessages: any[][] = [] + const observedStopHookActive: boolean[] = [] + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + + const yielded: any[] = [] + const terminal = await (async () => { + const generator = query({ + messages: [], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async () => ({ behavior: 'allow' }), + toolUseContext: makeToolUseContext(appStateRef), + querySource: 'sdk', + deps: { + uuid: () => `uuid-${modelCalls}`, + microcompact: async messages => ({ messages }), + autocompact: async () => ({ wasCompacted: false }), + goalEvaluationDeps: { + evaluateGoal: async () => decisions.shift()!, + saveGoalState: async () => {}, + }, + stopHookExecutionDeps: { + executeStopHooks: async function* ( + _permissionMode: string | undefined, + _signal: AbortSignal | undefined, + _timeoutMs: number | undefined, + stopHookActive: boolean, + ) { + observedStopHookActive.push(stopHookActive) + }, + isTeammate: () => false, + }, + callModel: async function* ({ messages }: any) { + modelCalls++ + modelRequestMessages.push(messages) + yield assistant( + `assistant-${modelCalls}`, + modelCalls === 1 ? 'Changed files.' : 'Tests pass.', + ) + }, + } as any, + }) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + expect(modelCalls).toBe(2) + expect(modelRequestMessages).toHaveLength(2) + expect(observedStopHookActive).toEqual([false, false]) + expect(terminal.reason).toBe('completed') + expect(appStateRef.current.goal?.status).toBe('achieved') + const yieldedGoalUsers = yielded.filter( + item => + item.type === 'user' && + item.isMeta && + typeof item.message.content === 'string' && + item.message.content.includes('Run tests.'), + ) + expect(yieldedGoalUsers).toHaveLength(1) + const followUpGoalUsers = modelRequestMessages[1].filter( + item => + item.type === 'user' && + item.isMeta && + typeof item.message.content === 'string' && + item.message.content.includes('Run tests.'), + ) + expect(followUpGoalUsers).toHaveLength(1) + expect( + yielded.some( + item => + item.type === 'system' && + typeof item.content === 'string' && + item.content.includes('Goal not complete:'), + ), + ).toBe(true) + expect( + yielded.some( + item => + item.type === 'system' && + typeof item.content === 'string' && + item.content.includes('Goal achieved:'), + ), + ).toBe(true) + }) + + test('shared query path skips goal evaluator when maxBudgetUsd is exhausted', async () => { + const { query } = await import('../query.js') + let modelCalls = 0 + let evaluatorCalls = 0 + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + const toolUseContext = makeToolUseContext(appStateRef) + toolUseContext.options.maxBudgetUsd = 0.25 + + const yielded: any[] = [] + const terminal = await (async () => { + const generator = query({ + messages: [], + systemPrompt: asSystemPrompt([]), + userContext: {}, + systemContext: {}, + canUseTool: async () => ({ behavior: 'allow' }), + toolUseContext, + querySource: 'sdk', + deps: { + uuid: () => `uuid-${modelCalls}`, + microcompact: async messages => ({ messages }), + autocompact: async () => ({ wasCompacted: false }), + goalEvaluationDeps: { + evaluateGoal: async () => { + evaluatorCalls++ + throw new Error('goal evaluator should not run') + }, + getTotalCost: () => 0.25, + saveGoalState: async () => {}, + }, + stopHookExecutionDeps: { + executeStopHooks: async function* () {}, + isTeammate: () => false, + }, + callModel: async function* () { + modelCalls++ + yield assistant('assistant-1', 'Done.') + }, + } as any, + }) + while (true) { + const next = await generator.next() + if (next.done) return next.value + yielded.push(next.value) + } + })() + + expect(modelCalls).toBe(1) + expect(evaluatorCalls).toBe(0) + expect(terminal.reason).toBe('completed') + expect(appStateRef.current.goal?.status).toBe('active') + expect(appStateRef.current.goal?.turnCount).toBe(0) + expect( + yielded.some( + item => + item.type === 'system' && + typeof item.content === 'string' && + item.content.includes('Goal not complete:'), + ), + ).toBe(false) + expect( + yielded.some( + item => + item.type === 'user' && + item.isMeta && + typeof item.message.content === 'string' && + item.message.content.includes('finish implementation'), + ), + ).toBe(false) + }) +}) diff --git a/src/query/stopHooks.goal.test.ts b/src/query/stopHooks.goal.test.ts new file mode 100644 index 000000000..1fce2825e --- /dev/null +++ b/src/query/stopHooks.goal.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test' + +import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' +import { createGoalState } from '../services/goal/state.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' +import { handleStopHooks } from './stopHooks.js' +import type { GoalEvaluationDeps } from '../services/goal/controller.js' + +function assistant(uuid: string, text: string) { + return { + type: 'assistant', + uuid, + message: { + id: uuid, + type: 'message', + role: 'assistant', + model: 'test-model', + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + content: [{ type: 'text', text }], + }, + } +} + +function makeToolUseContext(appStateRef: { current: AppState }) { + return { + options: { + isNonInteractiveSession: true, + }, + abortController: new AbortController(), + getAppState: () => appStateRef.current, + setAppState: (updater: (prev: AppState) => AppState) => { + appStateRef.current = updater(appStateRef.current) + }, + } as any +} + +async function drain( + generator: AsyncGenerator, +): Promise<{ yielded: any[]; returned: any }> { + const yielded: any[] = [] + while (true) { + const next = await generator.next() + if (next.done) return { yielded, returned: next.value } + yielded.push(next.value) + } +} + +describe('goal continuation stop-hook precedence', () => { + test('configured Stop hook blocking wins before goal evaluation', async () => { + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + let goalCalls = 0 + const goalEvaluationDeps: GoalEvaluationDeps = { + evaluateGoal: async () => { + goalCalls++ + throw new Error('goal evaluator should not run') + }, + saveGoalState: async () => {}, + } + + const { returned } = await drain( + handleStopHooks( + [], + [assistant('assistant-1', 'Done.') as any], + asSystemPrompt([]), + {}, + {}, + makeToolUseContext(appStateRef), + 'sdk', + false, + goalEvaluationDeps, + { + executeStopHooks: async function* () { + yield { blockingError: { blockingError: 'blocked' } } as any + }, + getStopHookMessage: () => 'stop hook blocked', + isTeammate: () => false, + }, + ), + ) + + expect(goalCalls).toBe(0) + expect(returned.preventContinuation).toBe(false) + expect(returned.stopHookActive).toBe(true) + expect(returned.blockingErrors).toHaveLength(1) + expect(returned.blockingErrors[0].message.content).toBe( + 'stop hook blocked', + ) + }) + + test('configured Stop hook preventContinuation wins before goal evaluation', async () => { + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + let goalCalls = 0 + const goalEvaluationDeps: GoalEvaluationDeps = { + evaluateGoal: async () => { + goalCalls++ + throw new Error('goal evaluator should not run') + }, + saveGoalState: async () => {}, + } + + const { returned } = await drain( + handleStopHooks( + [], + [assistant('assistant-1', 'Done.') as any], + asSystemPrompt([]), + {}, + {}, + makeToolUseContext(appStateRef), + 'sdk', + false, + goalEvaluationDeps, + { + executeStopHooks: async function* () { + yield { + preventContinuation: true, + stopReason: 'hook stopped continuation', + } as any + }, + isTeammate: () => false, + }, + ), + ) + + expect(goalCalls).toBe(0) + expect(returned).toEqual({ + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + }) + }) + + test('goal continuation is not marked as active Stop-hook recursion', async () => { + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + const goalEvaluationDeps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: false, + confidence: 0.7, + decision: 'incomplete', + reason: 'Tests have not been run.', + nextInstruction: 'Run tests.', + }), + saveGoalState: async () => {}, + } + + const { returned } = await drain( + handleStopHooks( + [], + [assistant('assistant-1', 'Done.') as any], + asSystemPrompt([]), + {}, + {}, + makeToolUseContext(appStateRef), + 'sdk', + false, + goalEvaluationDeps, + { + executeStopHooks: async function* () {}, + isTeammate: () => false, + }, + ), + ) + + expect(returned.preventContinuation).toBe(false) + expect(returned.blockingErrors).toHaveLength(1) + expect(returned.stopHookActive).toBe(false) + }) + + test('goal continuation user message is yielded for transcript persistence', async () => { + const appStateRef = { + current: { + ...getDefaultAppState(), + goal: createGoalState('finish implementation'), + }, + } + const goalEvaluationDeps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: false, + confidence: 0.7, + decision: 'incomplete', + reason: 'Tests have not been run.', + nextInstruction: 'Run tests.', + }), + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + handleStopHooks( + [], + [assistant('assistant-1', 'Done.') as any], + asSystemPrompt([]), + {}, + {}, + makeToolUseContext(appStateRef), + 'sdk', + false, + goalEvaluationDeps, + { + executeStopHooks: async function* () {}, + isTeammate: () => false, + }, + ), + ) + + const yieldedUserMessages = yielded.filter( + message => message.type === 'user', + ) + expect(returned.blockingErrors).toHaveLength(1) + expect(yieldedUserMessages).toHaveLength(1) + expect(yieldedUserMessages[0]).toBe(returned.blockingErrors[0]) + expect(yieldedUserMessages[0].message.content).toContain('Run tests.') + }) +}) diff --git a/src/query/stopHooks.ts b/src/query/stopHooks.ts index 1118086cf..dd251067f 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -50,6 +50,7 @@ const jobClassifierModule = feature('TEMPLATES') import type { QuerySource } from '../constants/querySource.js' import { executeAutoDream } from '../services/autoDream/autoDream.js' +import type { GoalEvaluationDeps } from '../services/goal/controller.js' import { executePromptSuggestion } from '../services/PromptSuggestion/promptSuggestion.js' import { isBareMode, isEnvDefinedFalsy } from '../utils/envUtils.js' import { @@ -60,6 +61,17 @@ import { type StopHookResult = { blockingErrors: Message[] preventContinuation: boolean + stopHookActive: boolean +} + +export type StopHookExecutionDeps = { + executeStopHooks?: typeof executeStopHooks + executeTaskCompletedHooks?: typeof executeTaskCompletedHooks + executeTeammateIdleHooks?: typeof executeTeammateIdleHooks + getStopHookMessage?: typeof getStopHookMessage + getTaskCompletedHookMessage?: typeof getTaskCompletedHookMessage + getTeammateIdleHookMessage?: typeof getTeammateIdleHookMessage + isTeammate?: typeof isTeammate } export async function* handleStopHooks( @@ -71,6 +83,8 @@ export async function* handleStopHooks( toolUseContext: ToolUseContext, querySource: QuerySource, stopHookActive?: boolean, + goalEvaluationDeps?: GoalEvaluationDeps, + stopHookExecutionDeps?: StopHookExecutionDeps, ): AsyncGenerator< | StreamEvent | RequestStartEvent @@ -80,6 +94,16 @@ export async function* handleStopHooks( StopHookResult > { const hookStartTime = Date.now() + const hookDeps = { + executeStopHooks, + executeTaskCompletedHooks, + executeTeammateIdleHooks, + getStopHookMessage, + getTaskCompletedHookMessage, + getTeammateIdleHookMessage, + isTeammate, + ...stopHookExecutionDeps, + } const stopHookContext: REPLHookContext = { messages: [...messagesForQuery, ...assistantMessages], @@ -177,7 +201,7 @@ export async function* handleStopHooks( const appState = toolUseContext.getAppState() const permissionMode = appState.toolPermissionContext.mode - const generator = executeStopHooks( + const generator = hookDeps.executeStopHooks( permissionMode, toolUseContext.abortController.signal, undefined, @@ -256,7 +280,7 @@ export async function* handleStopHooks( } if (result.blockingError) { const userMessage = createUserMessage({ - content: getStopHookMessage(result.blockingError), + content: hookDeps.getStopHookMessage(result.blockingError), isMeta: true, // Hide from UI (shown in summary message instead) }) blockingErrors.push(userMessage) @@ -290,7 +314,11 @@ export async function* handleStopHooks( yield createUserInterruptionMessage({ toolUse: false, }) - return { blockingErrors: [], preventContinuation: true } + return { + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + } } } @@ -323,16 +351,24 @@ export async function* handleStopHooks( } if (preventedContinuation) { - return { blockingErrors: [], preventContinuation: true } + return { + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + } } // Collect blocking errors from stop hooks if (blockingErrors.length > 0) { - return { blockingErrors, preventContinuation: false } + return { + blockingErrors, + preventContinuation: false, + stopHookActive: true, + } } // After Stop hooks pass, run TeammateIdle and TaskCompleted hooks if this is a teammate - if (isTeammate()) { + if (hookDeps.isTeammate()) { const teammateName = getAgentName() ?? '' const teamName = getTeamName() ?? '' const teammateBlockingErrors: Message[] = [] @@ -350,7 +386,7 @@ export async function* handleStopHooks( ) for (const task of inProgressTasks) { - const taskCompletedGenerator = executeTaskCompletedHooks( + const taskCompletedGenerator = hookDeps.executeTaskCompletedHooks( task.id, task.subject, task.description, @@ -374,7 +410,9 @@ export async function* handleStopHooks( } if (result.blockingError) { const userMessage = createUserMessage({ - content: getTaskCompletedHookMessage(result.blockingError), + content: hookDeps.getTaskCompletedHookMessage( + result.blockingError, + ), isMeta: true, }) teammateBlockingErrors.push(userMessage) @@ -394,13 +432,17 @@ export async function* handleStopHooks( }) } if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } + return { + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + } } } } // Run TeammateIdle hooks - const teammateIdleGenerator = executeTeammateIdleHooks( + const teammateIdleGenerator = hookDeps.executeTeammateIdleHooks( teammateName, teamName, permissionMode, @@ -416,7 +458,7 @@ export async function* handleStopHooks( } if (result.blockingError) { const userMessage = createUserMessage({ - content: getTeammateIdleHookMessage(result.blockingError), + content: hookDeps.getTeammateIdleHookMessage(result.blockingError), isMeta: true, }) teammateBlockingErrors.push(userMessage) @@ -436,23 +478,70 @@ export async function* handleStopHooks( }) } if (toolUseContext.abortController.signal.aborted) { - return { blockingErrors: [], preventContinuation: true } + return { + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + } } } if (teammatePreventedContinuation) { - return { blockingErrors: [], preventContinuation: true } + return { + blockingErrors: [], + preventContinuation: true, + stopHookActive: false, + } } if (teammateBlockingErrors.length > 0) { return { blockingErrors: teammateBlockingErrors, preventContinuation: false, + stopHookActive: false, } } } - return { blockingErrors: [], preventContinuation: false } + const activeGoal = toolUseContext.getAppState().goal + const terminalAssistantUuid = assistantMessages.at(-1)?.uuid + const isMainThreadGoalQuery = + !toolUseContext.agentId && + typeof querySource === 'string' && + (querySource === 'sdk' || querySource.startsWith('repl_main_thread')) + if ( + activeGoal?.status === 'active' && + isMainThreadGoalQuery && + terminalAssistantUuid && + activeGoal.lastEvaluatedMessageUuid !== terminalAssistantUuid + ) { + const { evaluateGoalAfterTurn } = await import( + '../services/goal/controller.js' + ) + const goalBlockingErrors = yield* evaluateGoalAfterTurn({ + messagesForQuery, + assistantMessages, + toolUseContext, + querySource, + deps: goalEvaluationDeps, + }) + if (goalBlockingErrors.length > 0) { + for (const userMessage of goalBlockingErrors) { + yield userMessage + } + return { + blockingErrors: goalBlockingErrors, + preventContinuation: false, + stopHookActive: false, + } + } + } + + return { + blockingErrors: [], + preventContinuation: false, + stopHookActive: false, + } } catch (error) { const durationMs = Date.now() - hookStartTime logEvent('tengu_stop_hook_error', { @@ -468,6 +557,10 @@ export async function* handleStopHooks( `Stop hook failed: ${errorMessage(error)}`, 'warning', ) - return { blockingErrors: [], preventContinuation: false } + return { + blockingErrors: [], + preventContinuation: false, + stopHookActive: false, + } } } diff --git a/src/queryEngine.goal.test.ts b/src/queryEngine.goal.test.ts new file mode 100644 index 000000000..1912c9bbb --- /dev/null +++ b/src/queryEngine.goal.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'bun:test' + +import { toSDKGoalStatusMessage } from './services/goal/sdk.js' +import { isGoalStatusSystemMessage } from './services/goal/status.js' +import { createSystemMessage } from './utils/messages.js' + +describe('QueryEngine goal status visibility', () => { + test('recognizes only goal status informational messages for SDK visibility', () => { + expect( + isGoalStatusSystemMessage({ + type: 'system', + subtype: 'informational', + content: 'Goal achieved: tests pass', + } as any), + ).toBe(true) + + expect( + isGoalStatusSystemMessage({ + type: 'system', + subtype: 'informational', + content: 'Goal not complete: tests missing', + } as any), + ).toBe(true) + + expect( + isGoalStatusSystemMessage({ + type: 'system', + subtype: 'informational', + content: 'Goal paused: evaluator failed', + } as any), + ).toBe(true) + + expect( + isGoalStatusSystemMessage({ + type: 'system', + subtype: 'informational', + content: 'Stop hook failed: bad hook', + } as any), + ).toBe(false) + }) + + test('maps goal status system messages to SDK assistant output', () => { + const systemMessage = createSystemMessage( + 'Goal achieved: tests pass', + 'info', + ) + const goalStatusMessage = toSDKGoalStatusMessage(systemMessage) + + expect(goalStatusMessage).toBeTruthy() + expect(goalStatusMessage?.type).toBe('assistant') + expect(goalStatusMessage?.message.content[0]).toEqual({ + type: 'text', + text: 'Goal achieved: tests pass', + }) + expect(goalStatusMessage?.parent_tool_use_id).toBeNull() + expect(goalStatusMessage?.uuid).toBe(systemMessage.uuid) + }) + + test('QueryEngine.submitMessage forwards goal status as SDK assistant output', async () => { + const proc = Bun.spawn( + [process.execPath, 'src/test/fixtures/queryEngineGoalStatus.fixture.ts'], + { + cwd: process.cwd(), + stderr: 'pipe', + stdout: 'pipe', + }, + ) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]) + + expect( + { exitCode, stderr, stdout }, + `fixture failed\nstdout:\n${stdout}\nstderr:\n${stderr}`, + ).toEqual({ + exitCode: 0, + stderr: '', + stdout: '', + }) + }) +}) diff --git a/src/services/api/xaiOAuthCallback.test.ts b/src/services/api/xaiOAuthCallback.test.ts index 46e99382b..ff980eb69 100644 --- a/src/services/api/xaiOAuthCallback.test.ts +++ b/src/services/api/xaiOAuthCallback.test.ts @@ -27,10 +27,12 @@ async function requestLoopback( method?: string headers?: Record body?: string + timeoutMs?: number } = {}, ): Promise { const method = options.method ?? 'GET' const body = options.body ?? '' + const timeoutMs = options.timeoutMs ?? 2_000 const headers = { Host: `127.0.0.1:${port}`, Connection: 'close', @@ -47,6 +49,16 @@ async function requestLoopback( return new Promise((resolve, reject) => { const socket = connect({ host: '127.0.0.1', port }) const chunks: Buffer[] = [] + let settled = false + + const fail = (error: Error) => { + if (settled) return + settled = true + socket.destroy() + reject(error) + } + + socket.setTimeout(timeoutMs) socket.on('connect', () => { socket.write(requestText) @@ -54,8 +66,13 @@ async function requestLoopback( socket.on('data', chunk => { chunks.push(Buffer.from(chunk)) }) - socket.on('error', reject) + socket.on('timeout', () => { + fail(new Error(`Loopback request timed out after ${timeoutMs}ms`)) + }) + socket.on('error', fail) socket.on('end', () => { + if (settled) return + settled = true const raw = Buffer.concat(chunks).toString('utf8') const [head, ...bodyParts] = raw.split('\r\n\r\n') const [statusLine, ...headerLines] = head.split('\r\n') diff --git a/src/services/goal/controller.test.ts b/src/services/goal/controller.test.ts new file mode 100644 index 000000000..5ab5d397f --- /dev/null +++ b/src/services/goal/controller.test.ts @@ -0,0 +1,525 @@ +import { describe, expect, test } from 'bun:test' + +import { getDefaultAppState, type AppState } from '../../state/AppStateStore.js' +import { createGoalState, markGoalEvaluated } from './state.js' +import { + evaluateGoalAfterTurn, + type GoalEvaluationDeps, +} from './controller.js' +import type { GoalState } from './types.js' + +function assistant(uuid: string, text: string) { + return { + type: 'assistant', + uuid, + message: { + role: 'assistant', + content: [{ type: 'text', text }], + }, + } +} + +function makeContext(goal = createGoalState('finish implementation')) { + let state: AppState = { + ...getDefaultAppState(), + goal, + } + const abortController = new AbortController() + const context = { + getAppState: () => state, + setAppState: (updater: (prev: AppState) => AppState) => { + state = updater(state) + }, + abortController, + options: { + isNonInteractiveSession: false, + }, + } as any + + return { + context, + abortController, + getState: () => state, + } +} + +async function drain( + generator: AsyncGenerator, +): Promise<{ yielded: any[]; returned: any }> { + const yielded: any[] = [] + while (true) { + const next = await generator.next() + if (next.done) return { yielded, returned: next.value } + yielded.push(next.value) + } +} + +describe('goal continuation controller', () => { + test('evaluator complete => no blocking error, goal achieved', async () => { + const { context, getState } = makeContext() + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: true, + confidence: 0.9, + decision: 'complete', + reason: 'Everything requested is done.', + nextInstruction: null, + }), + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(returned).toEqual([]) + expect(getState().goal?.status).toBe('achieved') + expect(yielded[0]?.content).toContain('Goal achieved:') + }) + + test('goal persistence failures are reported without failing the turn', async () => { + const { context, getState } = makeContext() + const persistenceError = new Error('write failed') + let observedGoal: GoalState | null | undefined + let observedError: unknown + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: true, + confidence: 0.9, + decision: 'complete', + reason: 'Everything requested is done.', + nextInstruction: null, + }), + saveGoalState: async () => { + throw persistenceError + }, + logGoalPersistenceFailure: (goal, error) => { + observedGoal = goal + observedError = error + }, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(returned).toEqual([]) + expect(getState().goal?.status).toBe('achieved') + expect(yielded[0]?.content).toContain('Goal achieved:') + expect(observedGoal?.id).toBe(getState().goal?.id) + expect(observedError).toBe(persistenceError) + }) + + test('evaluator incomplete => blocking/meta continuation message returned', async () => { + const { context, getState } = makeContext() + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: false, + confidence: 0.7, + decision: 'incomplete', + reason: 'Tests have not been run.', + nextInstruction: 'Run the focused tests.', + }), + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Changed files.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(getState().goal?.turnCount).toBe(1) + expect(yielded[0]?.content).toContain('Goal not complete:') + expect(returned).toHaveLength(1) + expect(returned[0].isMeta).toBe(true) + expect(returned[0].message.content).toContain('finish implementation') + expect(returned[0].message.content).toContain('Run the focused tests.') + }) + + for (const decisionType of ['malformed', 'error'] as const) { + test(`evaluator ${decisionType} pauses the goal without continuation`, async () => { + const { context, getState } = makeContext() + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: false, + confidence: 0, + decision: decisionType, + reason: + decisionType === 'malformed' + ? 'Goal evaluator returned malformed JSON.' + : 'Goal evaluator failed.', + nextInstruction: 'Continue directly toward the goal.', + }), + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(returned).toEqual([]) + expect(getState().goal?.status).toBe('paused') + expect(getState().goal?.lastDecision).toBe(decisionType) + expect(getState().goal?.evaluatorFailures).toBe(1) + expect(yielded[0]?.level).toBe('warning') + expect(yielded[0]?.content).toContain('Goal paused:') + }) + } + + test('passes only a bounded recent message slice to evaluator', async () => { + const { context } = makeContext() + let observedMessages: unknown[] = [] + const deps: GoalEvaluationDeps = { + evaluateGoal: async ({ messages }) => { + observedMessages = messages + return { + complete: true, + confidence: 0.9, + decision: 'complete', + reason: 'Done.', + nextInstruction: null, + } + }, + saveGoalState: async () => {}, + } + + await drain( + evaluateGoalAfterTurn({ + messagesForQuery: Array.from({ length: 100 }, (_, i) => + assistant(`prior-${i}`, `prior ${i}`), + ) as any, + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(observedMessages.length).toBeLessThanOrEqual(24) + expect((observedMessages.at(-1) as any).uuid).toBe('assistant-1') + }) + + test('no goal evaluation for subagents/agent query sources', async () => { + const { context } = makeContext() + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + } + + await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: { ...context, agentId: 'agent-1' }, + querySource: 'repl_main_thread', + deps, + }), + ) + await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-2', 'Done.')], + toolUseContext: context, + querySource: 'agent:custom', + deps, + }), + ) + + expect(calls).toBe(0) + }) + + test('no duplicate continuation for same terminal message', async () => { + const goal = markGoalEvaluated(createGoalState('finish implementation'), { + evaluatedMessageUuid: 'assistant-1', + decision: 'incomplete', + reason: 'not done', + nextInstruction: null, + }) + const { context } = makeContext(goal) + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + } + + const { returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Still done.')], + toolUseContext: context, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(calls).toBe(0) + expect(returned).toEqual([]) + }) + + test('abort prevents continuation', async () => { + const { context, abortController } = makeContext() + abortController.abort() + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + } + + const { returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(calls).toBe(0) + expect(returned).toEqual([]) + }) + + test('maxBudgetUsd exhaustion skips evaluator before spending on goal evaluation', async () => { + const { context, getState } = makeContext() + context.options.maxBudgetUsd = 0.25 + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + return { + complete: false, + confidence: 0.7, + decision: 'incomplete', + reason: 'This would require another paid evaluator call.', + nextInstruction: 'Continue.', + } + }, + saveGoalState: async () => {}, + getTotalCost: () => 0.25, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(calls).toBe(0) + expect(yielded).toEqual([]) + expect(returned).toEqual([]) + expect(getState().goal?.status).toBe('active') + expect(getState().goal?.turnCount).toBe(0) + }) + + test('maxBudgetUsd below cap still permits goal evaluation', async () => { + const { context, getState } = makeContext() + context.options.maxBudgetUsd = 0.25 + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + return { + complete: false, + confidence: 0.7, + decision: 'incomplete', + reason: 'Validation is still missing.', + nextInstruction: 'Run validation.', + } + }, + saveGoalState: async () => {}, + getTotalCost: () => 0.24, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(calls).toBe(1) + expect(getState().goal?.turnCount).toBe(1) + expect(yielded[0]?.content).toContain('Goal not complete:') + expect(returned).toHaveLength(1) + expect(returned[0].message.content).toContain('Run validation.') + }) + + test('maxBudgetUsd exhaustion still allows maxTurns pause without evaluator spend', async () => { + const goal = { + ...createGoalState('finish implementation'), + turnCount: 1, + maxTurns: 1, + } + const { context, getState } = makeContext(goal) + context.options.maxBudgetUsd = 0.25 + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + getTotalCost: () => 0.25, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'sdk', + deps, + }), + ) + + expect(calls).toBe(0) + expect(getState().goal?.status).toBe('paused') + expect(getState().goal?.lastReason).toContain('maximum of 1 turns') + expect(yielded[0]?.content.startsWith('Goal paused:')).toBe(true) + expect(yielded[0]?.content).toContain('maximum of 1 turns') + expect(returned).toEqual([]) + }) + + test('pending interactive dialog prevents continuation', async () => { + const { context } = makeContext() + const blockedContext = { + ...context, + getAppState: () => ({ + ...context.getAppState(), + elicitation: { queue: [{}] }, + }), + } + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + } + + const { returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: blockedContext, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(calls).toBe(0) + expect(returned).toEqual([]) + }) + + test('maxTurns pauses without evaluating or continuing', async () => { + const goal = { + ...createGoalState('finish implementation'), + turnCount: 1, + maxTurns: 1, + } + const { context, getState } = makeContext(goal) + let calls = 0 + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => { + calls++ + throw new Error('should not evaluate') + }, + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(calls).toBe(0) + expect(getState().goal?.status).toBe('paused') + expect(getState().goal?.lastReason).toContain('maximum of 1 turns') + expect(yielded[0]?.content).toContain('maximum of 1 turns') + expect(returned).toEqual([]) + }) + + test('incomplete evaluation that reaches maxTurns does not continue', async () => { + const goal = { + ...createGoalState('finish implementation'), + turnCount: 1, + maxTurns: 2, + } + const { context, getState } = makeContext(goal) + const deps: GoalEvaluationDeps = { + evaluateGoal: async () => ({ + complete: false, + confidence: 0.8, + decision: 'incomplete', + reason: 'One required validation is still missing.', + nextInstruction: 'Run the final validation.', + }), + saveGoalState: async () => {}, + } + + const { yielded, returned } = await drain( + evaluateGoalAfterTurn({ + messagesForQuery: [], + assistantMessages: [assistant('assistant-1', 'Done.')], + toolUseContext: context, + querySource: 'repl_main_thread', + deps, + }), + ) + + expect(getState().goal?.status).toBe('paused') + expect(getState().goal?.turnCount).toBe(2) + expect(getState().goal?.lastReason).toContain( + 'One required validation is still missing.', + ) + expect(yielded[0]?.content.startsWith('Goal not complete:')).toBe(true) + expect(yielded[0]?.content).toContain('maximum of 2 turns') + expect(returned).toEqual([]) + }) +}) diff --git a/src/services/goal/controller.ts b/src/services/goal/controller.ts new file mode 100644 index 000000000..42c407667 --- /dev/null +++ b/src/services/goal/controller.ts @@ -0,0 +1,252 @@ +import { getSessionId } from '../../bootstrap/state.js' +import type { QuerySource } from '../../constants/querySource.js' +import { getTotalCost as getTotalCostDefault } from '../../cost-tracker.js' +import type { ToolUseContext } from '../../Tool.js' +import type { Message } from '../../types/message.js' +import { logForDebugging } from '../../utils/debug.js' +import { logForDiagnosticsNoPII } from '../../utils/diagLogs.js' +import { createSystemMessage, createUserMessage } from '../../utils/messages.js' +import { evaluateGoal as evaluateGoalDefault } from './evaluator.js' +import { buildGoalContinuationInstruction } from './instructions.js' +import { saveGoalState as saveGoalStateDefault } from './persistence.js' +import { + achieveGoal, + markGoalEvaluated, + nowIso, + pauseGoal, + pauseGoalAtMaxTurns, + shouldEvaluateGoal, +} from './state.js' +import type { GoalState } from './types.js' + +const GOAL_EVALUATION_MESSAGE_LIMIT = 24 +const GOAL_PERSISTENCE_ERROR_MESSAGE_LIMIT = 500 + +type GoalPersistenceFailureLogger = ( + goal: GoalState | null, + error: unknown, +) => void + +export type GoalEvaluationDeps = { + evaluateGoal?: typeof evaluateGoalDefault + getTotalCost?: typeof getTotalCostDefault + logGoalPersistenceFailure?: GoalPersistenceFailureLogger + saveGoalState?: typeof saveGoalStateDefault +} + +export function isMainThreadGoalSource( + querySource: QuerySource, + toolUseContext: ToolUseContext, +): boolean { + if (toolUseContext.agentId) return false + if (typeof querySource !== 'string') return false + return querySource === 'sdk' || querySource.startsWith('repl_main_thread') +} + +function hasPendingInteractiveDialog(toolUseContext: ToolUseContext): boolean { + const state = toolUseContext.getAppState() + return Boolean( + state.elicitation?.queue?.length || + state.pendingWorkerRequest || + state.pendingSandboxRequest || + state.activeOverlays?.size, + ) +} + +function terminalAssistantUuid(assistantMessages: Message[]): string | undefined { + return assistantMessages.at(-1)?.uuid +} + +function getRecentGoalEvaluationMessages( + messagesForQuery: Message[], + assistantMessages: Message[], +): Message[] { + return [ + ...messagesForQuery.slice(-GOAL_EVALUATION_MESSAGE_LIMIT), + ...assistantMessages.slice(-GOAL_EVALUATION_MESSAGE_LIMIT), + ].slice(-GOAL_EVALUATION_MESSAGE_LIMIT) +} + +async function persistGoal( + saveGoalState: typeof saveGoalStateDefault, + goal: GoalState | null, + logGoalPersistenceFailure: GoalPersistenceFailureLogger, +): Promise { + try { + await saveGoalState(goal) + } catch (error) { + // Goal persistence is important for resume, but should not crash a turn. + logGoalPersistenceFailure(goal, error) + } +} + +function describeGoalPersistenceError(error: unknown): { + name: string + message: string +} { + if (error instanceof Error) { + return { name: error.name, message: error.message } + } + return { name: typeof error, message: String(error) } +} + +function formatGoalPersistenceErrorMessage(message: string): string { + return message + .replace(/\s+/g, ' ') + .trim() + .slice(0, GOAL_PERSISTENCE_ERROR_MESSAGE_LIMIT) +} + +function logGoalPersistenceFailureDefault( + goal: GoalState | null, + error: unknown, +): void { + const sessionId = getSessionId() + const { name, message } = describeGoalPersistenceError(error) + const goalId = goal?.id ?? null + const goalStatus = goal?.status ?? null + + logForDiagnosticsNoPII('warn', 'goal_persistence_failed', { + session_id: sessionId, + goal_id: goalId, + goal_status: goalStatus, + error_name: name, + }) + logForDebugging( + [ + 'Goal persistence failed', + `session_id=${sessionId}`, + `goal_id=${goalId ?? 'none'}`, + `goal_status=${goalStatus ?? 'none'}`, + `error_name=${name}`, + `error_message=${formatGoalPersistenceErrorMessage(message)}`, + ].join(' '), + { level: 'warn' }, + ) +} + +export async function* evaluateGoalAfterTurn({ + messagesForQuery, + assistantMessages, + toolUseContext, + querySource, + deps = {}, +}: { + messagesForQuery: Message[] + assistantMessages: Message[] + toolUseContext: ToolUseContext + querySource: QuerySource + deps?: GoalEvaluationDeps +}): AsyncGenerator { + const evaluateGoal = deps.evaluateGoal ?? evaluateGoalDefault + const getTotalCost = deps.getTotalCost ?? getTotalCostDefault + const logGoalPersistenceFailure = + deps.logGoalPersistenceFailure ?? logGoalPersistenceFailureDefault + const saveGoalState = deps.saveGoalState ?? saveGoalStateDefault + const terminalUuid = terminalAssistantUuid(assistantMessages) + const appState = toolUseContext.getAppState() + const goal = appState.goal ?? null + + if (!isMainThreadGoalSource(querySource, toolUseContext)) return [] + if (!goal || goal.status !== 'active') return [] + if (!terminalUuid) return [] + if (goal.lastEvaluatedMessageUuid === terminalUuid) return [] + if (toolUseContext.abortController.signal.aborted) return [] + if (hasPendingInteractiveDialog(toolUseContext)) return [] + + if (goal.turnCount >= goal.maxTurns) { + const paused = pauseGoalAtMaxTurns(goal, terminalUuid, nowIso()) + toolUseContext.setAppState(prev => ({ ...prev, goal: paused })) + await persistGoal(saveGoalState, paused, logGoalPersistenceFailure) + yield createSystemMessage( + paused.lastReason ?? + 'Goal paused: automatic continuation has been paused.', + 'warning', + ) + return [] + } + if (!shouldEvaluateGoal(goal, terminalUuid)) return [] + if ( + toolUseContext.options.maxBudgetUsd !== undefined && + getTotalCost() >= toolUseContext.options.maxBudgetUsd + ) { + return [] + } + + const decision = await evaluateGoal({ + goal, + messages: getRecentGoalEvaluationMessages( + messagesForQuery, + assistantMessages, + ), + signal: toolUseContext.abortController.signal, + isNonInteractiveSession: + toolUseContext.options.isNonInteractiveSession ?? false, + }) + + if (toolUseContext.abortController.signal.aborted) return [] + + if (decision.complete) { + const achieved = achieveGoal(goal, { + evaluatedMessageUuid: terminalUuid, + reason: decision.reason, + nextInstruction: decision.nextInstruction, + }) + toolUseContext.setAppState(prev => ({ ...prev, goal: achieved })) + await persistGoal(saveGoalState, achieved, logGoalPersistenceFailure) + yield createSystemMessage(`Goal achieved: ${decision.reason}`, 'info') + return [] + } + + if (decision.decision === 'malformed' || decision.decision === 'error') { + const now = nowIso() + const paused = pauseGoal( + markGoalEvaluated(goal, { + evaluatedMessageUuid: terminalUuid, + decision: decision.decision, + reason: decision.reason, + nextInstruction: null, + now, + }), + now, + ) + toolUseContext.setAppState(prev => ({ ...prev, goal: paused })) + await persistGoal(saveGoalState, paused, logGoalPersistenceFailure) + yield createSystemMessage(`Goal paused: ${decision.reason}`, 'warning') + return [] + } + + const updatedGoal = markGoalEvaluated(goal, { + evaluatedMessageUuid: terminalUuid, + decision: decision.decision === 'complete' ? 'incomplete' : decision.decision, + reason: decision.reason, + nextInstruction: decision.nextInstruction, + }) + + if (updatedGoal.turnCount >= updatedGoal.maxTurns) { + const paused = pauseGoalAtMaxTurns( + updatedGoal, + terminalUuid, + nowIso(), + decision.reason, + ) + toolUseContext.setAppState(prev => ({ ...prev, goal: paused })) + await persistGoal(saveGoalState, paused, logGoalPersistenceFailure) + yield createSystemMessage( + `Goal not complete: ${decision.reason} Goal paused after reaching the maximum of ${updatedGoal.maxTurns} turns.`, + 'warning', + ) + return [] + } + + toolUseContext.setAppState(prev => ({ ...prev, goal: updatedGoal })) + await persistGoal(saveGoalState, updatedGoal, logGoalPersistenceFailure) + yield createSystemMessage(`Goal not complete: ${decision.reason}`, 'info') + + return [ + createUserMessage({ + content: buildGoalContinuationInstruction(updatedGoal, decision), + isMeta: true, + }), + ] +} diff --git a/src/services/goal/evaluator.test.ts b/src/services/goal/evaluator.test.ts new file mode 100644 index 000000000..9a522ab9f --- /dev/null +++ b/src/services/goal/evaluator.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, test } from 'bun:test' + +import { createGoalState } from './state.js' +import { + buildGoalEvaluatorPrompt, + evaluateGoal, + type GoalModelCaller, +} from './evaluator.js' + +function user(uuid: string, content: string) { + return { + type: 'user', + uuid, + message: { role: 'user', content }, + } +} + +function assistant(uuid: string, content: string) { + return { + type: 'assistant', + uuid, + message: { + role: 'assistant', + content: [{ type: 'text', text: content }], + }, + } +} + +describe('goal evaluator', () => { + test('valid complete JSON', async () => { + const caller: GoalModelCaller = async () => + JSON.stringify({ + complete: true, + confidence: 0.92, + reason: 'The implementation and tests are complete.', + next_instruction: null, + }) + + const decision = await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [user('u1', 'please implement it'), assistant('a1', 'Done.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(decision.complete).toBe(true) + expect(decision.decision).toBe('complete') + expect(decision.reason).toContain('complete') + expect(decision.nextInstruction).toBeNull() + }) + + test('valid incomplete JSON', async () => { + const caller: GoalModelCaller = async () => + JSON.stringify({ + complete: false, + confidence: 0.7, + reason: 'Tests have not been run yet.', + next_instruction: 'Run the focused tests.', + }) + + const decision = await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [user('u1', 'please implement it'), assistant('a1', 'I changed files.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(decision.complete).toBe(false) + expect(decision.decision).toBe('incomplete') + expect(decision.reason).toBe('Tests have not been run yet.') + expect(decision.nextInstruction).toBe('Run the focused tests.') + }) + + test('malformed JSON retries once', async () => { + let calls = 0 + const caller: GoalModelCaller = async () => { + calls++ + return calls === 1 + ? 'not json' + : JSON.stringify({ + complete: true, + confidence: 0.8, + reason: 'Recovered on retry.', + next_instruction: null, + }) + } + + const decision = await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [assistant('a1', 'Done.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(calls).toBe(2) + expect(decision.complete).toBe(true) + expect(decision.decision).toBe('complete') + }) + + test('malformed JSON after retry returns fail-closed decision', async () => { + let calls = 0 + const caller: GoalModelCaller = async () => { + calls++ + return calls === 1 ? '```json\n[]\n```' : '{"complete":"yes"}' + } + + const decision = await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [assistant('a1', 'Done.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(calls).toBe(2) + expect(decision.complete).toBe(false) + expect(decision.decision).toBe('malformed') + expect(decision.reason).toContain('malformed JSON') + expect(decision.nextInstruction).toBeNull() + }) + + test('model caller errors return fail-closed decision', async () => { + const caller: GoalModelCaller = async () => { + throw new Error('provider auth failed') + } + + const decision = await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [assistant('a1', 'Done.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(decision.complete).toBe(false) + expect(decision.decision).toBe('error') + expect(decision.reason).toContain('failed') + expect(decision.nextInstruction).toBeNull() + }) + + test('bounded context size', () => { + const prompt = buildGoalEvaluatorPrompt({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: Array.from({ length: 40 }, (_, i) => + user(`u${i}`, `message-${i} ${'x'.repeat(5_000)}`), + ), + }) + + expect(prompt.length).toBeLessThanOrEqual(16_000) + expect(prompt).not.toContain('x'.repeat(5_000)) + }) + + test('includes visible tool-use summary text in bounded context', () => { + const prompt = buildGoalEvaluatorPrompt({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [ + { + type: 'tool_use_summary', + summary: 'Ran focused goal tests', + precedingToolUseIds: ['tool-1'], + }, + ], + }) + + expect(prompt).toContain('tool-summary: Ran focused goal tests') + }) + + test('no tools passed to evaluator', async () => { + let observedTools: unknown + const caller: GoalModelCaller = async request => { + observedTools = request.tools + return JSON.stringify({ + complete: true, + confidence: 1, + reason: 'Complete.', + next_instruction: null, + }) + } + + await evaluateGoal({ + goal: createGoalState('finish implementation', '2026-05-21T10:00:00.000Z'), + messages: [assistant('a1', 'Done.')], + signal: new AbortController().signal, + isNonInteractiveSession: false, + modelCaller: caller, + }) + + expect(observedTools).toEqual([]) + }) +}) diff --git a/src/services/goal/evaluator.ts b/src/services/goal/evaluator.ts new file mode 100644 index 000000000..206dc35f2 --- /dev/null +++ b/src/services/goal/evaluator.ts @@ -0,0 +1,279 @@ +import type { SystemPrompt } from '../../utils/systemPromptType.js' +import { asSystemPrompt } from '../../utils/systemPromptType.js' +import { queryHaiku } from '../api/claude.js' +import type { GoalEvaluatorDecision, GoalState } from './types.js' + +const GOAL_EVALUATOR_SYSTEM_PROMPT = `You evaluate whether a coding agent has completed a session goal. + +Return strict JSON only: +{ + "complete": boolean, + "confidence": number, + "reason": string, + "next_instruction": string | null +} + +Rules: +- Mark complete only when the recent conversation shows the goal condition is satisfied. +- If verification is missing for a development task, mark incomplete. +- Keep reason and next_instruction concise. +- Do not ask questions.` + +const GOAL_EVALUATOR_OUTPUT_FORMAT = { + type: 'json_schema' as const, + schema: { + type: 'object', + properties: { + complete: { type: 'boolean' }, + confidence: { type: 'number' }, + reason: { type: 'string' }, + next_instruction: { + anyOf: [{ type: 'string' }, { type: 'null' }], + }, + }, + required: ['complete', 'confidence', 'reason', 'next_instruction'], + additionalProperties: false, + }, +} + +const MAX_CONTEXT_CHARS = 12_000 +const MAX_PROMPT_CHARS = 16_000 +const MAX_MESSAGE_CHARS = 1_200 +const RECENT_MESSAGE_LIMIT = 20 + +export type GoalModelRequest = { + systemPrompt: SystemPrompt + userPrompt: string + signal: AbortSignal + isNonInteractiveSession: boolean + tools: [] +} + +export type GoalModelCaller = ( + request: GoalModelRequest, +) => Promise + +const defaultModelCaller: GoalModelCaller = async request => { + const response = await queryHaiku({ + systemPrompt: request.systemPrompt, + userPrompt: request.userPrompt, + outputFormat: GOAL_EVALUATOR_OUTPUT_FORMAT, + signal: request.signal, + options: { + querySource: 'goal_evaluation', + enablePromptCaching: false, + agents: [], + isNonInteractiveSession: request.isNonInteractiveSession, + hasAppendSystemPrompt: false, + mcpTools: [], + }, + }) + + return response.message.content + .filter((block: { type: string }) => block.type === 'text') + .map((block: { text?: string }) => block.text ?? '') + .join('') + .trim() +} + +function truncateText(text: string, maxChars: number): string { + if (text.length <= maxChars) return text + return text.slice(0, maxChars - 15).trimEnd() + '... [truncated]' +} + +function contentToText(content: unknown): string { + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + + const parts: string[] = [] + for (const block of content) { + if (!block || typeof block !== 'object') continue + const record = block as Record + if (record.type === 'text' && typeof record.text === 'string') { + parts.push(record.text) + } else if (record.type === 'tool_result') { + const raw = record.content + const text = + typeof raw === 'string' + ? raw + : Array.isArray(raw) + ? raw + .map(item => + item && + typeof item === 'object' && + (item as Record).type === 'text' && + typeof (item as Record).text === 'string' + ? ((item as Record).text as string) + : '', + ) + .filter(Boolean) + .join('\n') + : '' + if (text) parts.push(`Tool result: ${text}`) + } else if (record.type === 'tool_use' && typeof record.name === 'string') { + parts.push(`Tool use: ${record.name}`) + } + } + return parts.join('\n') +} + +function messageRole(message: Record): string | null { + if (message.type === 'assistant') return 'assistant' + if (message.type === 'user') return 'user' + if (message.type === 'system' && message.subtype === 'local_command') { + return 'local-command' + } + if (message.type === 'tool_use_summary') return 'tool-summary' + return null +} + +function messageText(message: Record): string { + if (message.type === 'system' && typeof message.content === 'string') { + return message.content + } + if ( + message.type === 'tool_use_summary' && + typeof message.summary === 'string' + ) { + return message.summary + } + const nested = message.message + if (!nested || typeof nested !== 'object') return '' + return contentToText((nested as Record).content) +} + +function recentContext(messages: unknown[]): string { + const lines: string[] = [] + let total = 0 + const recent = messages.slice(-RECENT_MESSAGE_LIMIT).reverse() + + for (const item of recent) { + if (!item || typeof item !== 'object') continue + const record = item as Record + const role = messageRole(record) + if (!role) continue + + const text = truncateText(messageText(record).trim(), MAX_MESSAGE_CHARS) + if (!text) continue + + const line = `${role}: ${text}` + if (total + line.length > MAX_CONTEXT_CHARS) break + lines.push(line) + total += line.length + } + + return lines.reverse().join('\n\n') +} + +export function buildGoalEvaluatorPrompt({ + goal, + messages, +}: { + goal: GoalState + messages: unknown[] +}): string { + const prompt = [ + `Goal condition:\n${truncateText(goal.condition, 4_000)}`, + `Current goal turn count: ${goal.turnCount}/${goal.maxTurns}`, + `Last evaluator reason: ${goal.lastReason ?? 'none'}`, + `Recent conversation:\n${recentContext(messages) || '(no recent text)'}`, + 'Return strict JSON now.', + ].join('\n\n') + + return truncateText(prompt, MAX_PROMPT_CHARS) +} + +function stripJsonFence(raw: string): string { + let text = raw.trim() + if (text.startsWith('```')) { + text = text.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim() + } + const first = text.indexOf('{') + const last = text.lastIndexOf('}') + if (first !== -1 && last !== -1 && last > first) { + return text.slice(first, last + 1) + } + return text +} + +function parseDecision(raw: string): GoalEvaluatorDecision | null { + let parsed: unknown + try { + parsed = JSON.parse(stripJsonFence(raw)) + } catch { + return null + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return null + } + const obj = parsed as Record + if (typeof obj.complete !== 'boolean') return null + if (typeof obj.confidence !== 'number' || Number.isNaN(obj.confidence)) { + return null + } + if (typeof obj.reason !== 'string') return null + if ( + obj.next_instruction !== null && + typeof obj.next_instruction !== 'string' + ) { + return null + } + + return { + complete: obj.complete, + confidence: Math.max(0, Math.min(1, obj.confidence)), + decision: obj.complete ? 'complete' : 'incomplete', + reason: truncateText(obj.reason.trim() || 'No reason provided.', 1_000), + nextInstruction: + typeof obj.next_instruction === 'string' + ? truncateText(obj.next_instruction.trim(), 1_000) || null + : null, + raw, + } +} + +export async function evaluateGoal({ + goal, + messages, + signal, + isNonInteractiveSession, + modelCaller = defaultModelCaller, +}: { + goal: GoalState + messages: unknown[] + signal: AbortSignal + isNonInteractiveSession: boolean + modelCaller?: GoalModelCaller +}): Promise { + const request: GoalModelRequest = { + systemPrompt: asSystemPrompt([GOAL_EVALUATOR_SYSTEM_PROMPT]), + userPrompt: buildGoalEvaluatorPrompt({ goal, messages }), + signal, + isNonInteractiveSession, + tools: [], + } + + try { + for (let attempt = 0; attempt < 2; attempt++) { + const raw = await modelCaller(request) + const parsed = parseDecision(raw) + if (parsed) return parsed + } + return { + complete: false, + confidence: 0, + decision: 'malformed', + reason: + 'Goal evaluator returned malformed JSON; pausing automatic goal continuation.', + nextInstruction: null, + } + } catch { + return { + complete: false, + confidence: 0, + decision: 'error', + reason: 'Goal evaluator failed; pausing automatic goal continuation.', + nextInstruction: null, + } + } +} diff --git a/src/services/goal/instructions.ts b/src/services/goal/instructions.ts new file mode 100644 index 000000000..16afee692 --- /dev/null +++ b/src/services/goal/instructions.ts @@ -0,0 +1,31 @@ +import type { GoalEvaluatorDecision, GoalState } from './types.js' + +export function buildGoalStartInstruction(goal: GoalState): string { + return [ + 'A session goal has been set.', + '', + `Goal condition:\n${goal.condition}`, + '', + 'Continue directly toward this goal. Use tools as needed. Do not stop only because one turn ended; stop when the goal is complete, a permission/user decision is needed, or you are blocked.', + ].join('\n') +} + +export function buildGoalContinuationInstruction( + goal: GoalState, + decision: GoalEvaluatorDecision, +): string { + return [ + 'Continue working toward the active session goal.', + '', + `Goal condition:\n${goal.condition}`, + '', + `Evaluator reason:\n${decision.reason}`, + decision.nextInstruction + ? `\nEvaluator next instruction:\n${decision.nextInstruction}` + : '', + '', + 'Continue directly and use tools as needed. Do not recap unless useful for the work. Preserve normal permission checks.', + ] + .filter(Boolean) + .join('\n') +} diff --git a/src/services/goal/persistence.ts b/src/services/goal/persistence.ts new file mode 100644 index 000000000..6fbcda2dd --- /dev/null +++ b/src/services/goal/persistence.ts @@ -0,0 +1,14 @@ +import type { UUID } from 'crypto' +import { getSessionId } from '../../bootstrap/state.js' +import type { GoalState } from './types.js' + +export async function saveGoalState(goal: GoalState | null): Promise { + if ( + process.env.NODE_ENV === 'test' && + process.env.TEST_ENABLE_SESSION_PERSISTENCE !== 'true' + ) { + return + } + const { recordGoalState } = await import('../../utils/sessionStorage.js') + await recordGoalState(goal, getSessionId() as UUID) +} diff --git a/src/services/goal/sdk.ts b/src/services/goal/sdk.ts new file mode 100644 index 000000000..fe2d16f82 --- /dev/null +++ b/src/services/goal/sdk.ts @@ -0,0 +1,8 @@ +import type { Message } from '../../types/message.js' +import { localCommandOutputToSDKAssistantMessage } from '../../utils/messages/mappers.js' +import { isGoalStatusSystemMessage } from './status.js' + +export function toSDKGoalStatusMessage(message: Message) { + if (!isGoalStatusSystemMessage(message)) return null + return localCommandOutputToSDKAssistantMessage(message.content, message.uuid) +} diff --git a/src/services/goal/state.test.ts b/src/services/goal/state.test.ts new file mode 100644 index 000000000..9b9278002 --- /dev/null +++ b/src/services/goal/state.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from 'bun:test' + +import { + DEFAULT_GOAL_MAX_TURNS, + achieveGoal, + clearGoal, + createGoalState, + markGoalEvaluated, + pauseGoal, + prepareGoalForSessionResume, + resumeGoal, + shouldEvaluateGoal, +} from './state.js' + +const now = '2026-05-21T10:00:00.000Z' +const later = '2026-05-21T10:05:00.000Z' + +describe('goal state transitions', () => { + test('set -> active', () => { + const goal = createGoalState('ship the feature', now) + + expect(goal.condition).toBe('ship the feature') + expect(goal.status).toBe('active') + expect(goal.turnCount).toBe(0) + expect(goal.maxTurns).toBe(DEFAULT_GOAL_MAX_TURNS) + expect(goal.evaluatorFailures).toBe(0) + }) + + test('active -> paused', () => { + const goal = pauseGoal(createGoalState('ship', now), later) + + expect(goal.status).toBe('paused') + expect(goal.pausedAt).toBe(later) + expect(goal.updatedAt).toBe(later) + }) + + test('paused -> active resets the active turn baseline', () => { + const paused = pauseGoal( + markGoalEvaluated(createGoalState('ship', now), { + evaluatedMessageUuid: 'assistant-1', + decision: 'incomplete', + reason: 'not done', + nextInstruction: 'keep going', + now, + }), + later, + ) + + const resumed = resumeGoal(paused, '2026-05-21T10:10:00.000Z') + + expect(resumed.status).toBe('active') + expect(resumed.turnCount).toBe(0) + expect(resumed.startedAt).toBe('2026-05-21T10:10:00.000Z') + expect(resumed.lastEvaluatedMessageUuid).toBeUndefined() + expect(resumed.resumedAt).toBe('2026-05-21T10:10:00.000Z') + }) + + test('active -> achieved', () => { + const goal = achieveGoal(createGoalState('ship', now), { + evaluatedMessageUuid: 'assistant-1', + reason: 'all requested work is done', + now: later, + }) + + expect(goal.status).toBe('achieved') + expect(goal.achievedAt).toBe(later) + expect(goal.turnCount).toBe(1) + expect(goal.lastDecision).toBe('complete') + expect(goal.lastReason).toBe('all requested work is done') + }) + + test('active/paused -> cleared', () => { + const active = createGoalState('ship', now) + const paused = pauseGoal(active, later) + + expect(clearGoal(active)).toBeNull() + expect(clearGoal(paused)).toBeNull() + }) + + test('duplicate evaluation guard by lastEvaluatedMessageUuid', () => { + const goal = markGoalEvaluated(createGoalState('ship', now), { + evaluatedMessageUuid: 'assistant-1', + decision: 'incomplete', + reason: 'not done', + nextInstruction: null, + now, + }) + + expect(shouldEvaluateGoal(goal, 'assistant-1')).toBe(false) + expect(shouldEvaluateGoal(goal, 'assistant-2')).toBe(true) + }) + + test('maxTurns guard', () => { + const goal = { + ...createGoalState('ship', now), + turnCount: DEFAULT_GOAL_MAX_TURNS, + } + + expect(shouldEvaluateGoal(goal, 'assistant-1')).toBe(false) + }) + + test('session resume resets active goals and does not auto-run inactive goals', () => { + const active = markGoalEvaluated(createGoalState('ship', now), { + evaluatedMessageUuid: 'assistant-1', + decision: 'incomplete', + reason: 'not done', + now, + }) + const restoredActive = prepareGoalForSessionResume(active, later) + + expect(restoredActive?.status).toBe('active') + expect(restoredActive?.turnCount).toBe(0) + expect(restoredActive?.startedAt).toBe(later) + expect(restoredActive?.lastEvaluatedMessageUuid).toBeUndefined() + + const achieved = prepareGoalForSessionResume( + achieveGoal(createGoalState('ship', now), { + evaluatedMessageUuid: 'assistant-1', + reason: 'done', + now, + }), + later, + ) + const cleared = prepareGoalForSessionResume( + { + ...createGoalState('ship', now), + status: 'cleared', + clearedAt: later, + }, + later, + ) + + expect(shouldEvaluateGoal(achieved, 'assistant-2')).toBe(false) + expect(shouldEvaluateGoal(cleared, 'assistant-2')).toBe(false) + }) +}) diff --git a/src/services/goal/state.ts b/src/services/goal/state.ts new file mode 100644 index 000000000..4b86a19fd --- /dev/null +++ b/src/services/goal/state.ts @@ -0,0 +1,181 @@ +import { randomUUID } from 'crypto' + +import type { GoalDecision, GoalState } from './types.js' + +export const DEFAULT_GOAL_MAX_TURNS = 50 +export const MAX_GOAL_CONDITION_CHARS = 4_000 + +export function nowIso(): string { + return new Date().toISOString() +} + +export function normalizeGoalCondition(input: string): string { + const trimmed = input.trim() + if ( + trimmed.length >= 2 && + ((trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'"))) + ) { + return trimmed.slice(1, -1).trim() + } + return trimmed +} + +export function validateGoalCondition(input: string): + | { ok: true; condition: string } + | { ok: false; error: string } { + const condition = normalizeGoalCondition(input) + if (!condition) { + return { ok: false, error: 'Goal condition cannot be empty.' } + } + if (condition.length > MAX_GOAL_CONDITION_CHARS) { + return { + ok: false, + error: 'Goal condition must be 4,000 characters or fewer.', + } + } + return { ok: true, condition } +} + +export function createGoalState( + condition: string, + now: string = nowIso(), + maxTurns = DEFAULT_GOAL_MAX_TURNS, +): GoalState { + return { + id: randomUUID(), + condition: condition.trim(), + status: 'active', + createdAt: now, + updatedAt: now, + startedAt: now, + turnCount: 0, + maxTurns, + evaluatorFailures: 0, + } +} + +export function pauseGoal(goal: GoalState, now: string = nowIso()): GoalState { + if (goal.status !== 'active') return goal + return { + ...goal, + status: 'paused', + pausedAt: now, + updatedAt: now, + } +} + +export function resumeGoal(goal: GoalState, now: string = nowIso()): GoalState { + if (goal.status !== 'paused' && goal.status !== 'active') return goal + return { + ...goal, + status: 'active', + startedAt: now, + resumedAt: now, + pausedAt: undefined, + updatedAt: now, + turnCount: 0, + lastEvaluatedMessageUuid: undefined, + } +} + +export function clearGoal(_goal: GoalState | null): null { + return null +} + +export function achieveGoal( + goal: GoalState, + opts: { + evaluatedMessageUuid: string + reason: string + nextInstruction?: string | null + now?: string + }, +): GoalState { + const now = opts.now ?? nowIso() + return { + ...goal, + status: 'achieved', + achievedAt: now, + updatedAt: now, + turnCount: goal.turnCount + 1, + lastEvaluatedMessageUuid: opts.evaluatedMessageUuid, + lastDecision: 'complete', + lastReason: opts.reason, + lastNextInstruction: opts.nextInstruction ?? undefined, + } +} + +export function markGoalEvaluated( + goal: GoalState, + opts: { + evaluatedMessageUuid: string + decision: Exclude + reason: string + nextInstruction?: string | null + now?: string + }, +): GoalState { + const now = opts.now ?? nowIso() + const evaluatorFailed = + opts.decision === 'malformed' || opts.decision === 'error' + return { + ...goal, + updatedAt: now, + turnCount: goal.turnCount + 1, + lastEvaluatedMessageUuid: opts.evaluatedMessageUuid, + lastDecision: opts.decision, + lastReason: opts.reason, + lastNextInstruction: opts.nextInstruction ?? undefined, + evaluatorFailures: goal.evaluatorFailures + (evaluatorFailed ? 1 : 0), + } +} + +export function pauseGoalAtMaxTurns( + goal: GoalState, + terminalMessageUuid: string, + now: string = nowIso(), + evaluatorReason?: string, +): GoalState { + const maxReason = `Goal paused: after reaching the maximum of ${goal.maxTurns} turns.` + return { + ...goal, + status: 'paused', + pausedAt: now, + updatedAt: now, + lastEvaluatedMessageUuid: terminalMessageUuid, + lastDecision: 'incomplete', + lastReason: evaluatorReason + ? `${maxReason} Last evaluator reason: ${evaluatorReason}` + : maxReason, + } +} + +export function shouldEvaluateGoal( + goal: GoalState | null | undefined, + terminalAssistantMessageUuid: string | undefined, +): boolean { + if (!goal || goal.status !== 'active') return false + if (!terminalAssistantMessageUuid) return false + if (goal.lastEvaluatedMessageUuid === terminalAssistantMessageUuid) { + return false + } + if (goal.turnCount >= goal.maxTurns) return false + return true +} + +export function prepareGoalForSessionResume( + goal: GoalState | null | undefined, + now: string = nowIso(), +): GoalState | null { + if (!goal) return null + if (goal.status !== 'active') return goal + return { + ...goal, + startedAt: now, + resumedAt: now, + updatedAt: now, + turnCount: 0, + lastEvaluatedMessageUuid: undefined, + } +} diff --git a/src/services/goal/status.ts b/src/services/goal/status.ts new file mode 100644 index 000000000..1544d55a5 --- /dev/null +++ b/src/services/goal/status.ts @@ -0,0 +1,18 @@ +export function isGoalStatusContent(content: string): boolean { + return ( + content.startsWith('Goal achieved:') || + content.startsWith('Goal not complete:') || + content.startsWith('Goal paused:') + ) +} + +export function isGoalStatusSystemMessage(message: unknown): boolean { + if (!message || typeof message !== 'object') return false + const record = message as Record + return ( + record.type === 'system' && + record.subtype === 'informational' && + typeof record.content === 'string' && + isGoalStatusContent(record.content) + ) +} diff --git a/src/services/goal/types.ts b/src/services/goal/types.ts new file mode 100644 index 000000000..c22c4a5db --- /dev/null +++ b/src/services/goal/types.ts @@ -0,0 +1,32 @@ +export type GoalStatus = 'active' | 'paused' | 'achieved' | 'cleared' + +export type GoalDecision = 'complete' | 'incomplete' | 'malformed' | 'error' + +export type GoalState = { + id: string + condition: string + status: GoalStatus + createdAt: string + updatedAt: string + startedAt: string + turnCount: number + maxTurns: number + lastEvaluatedMessageUuid?: string + lastDecision?: GoalDecision + lastReason?: string + lastNextInstruction?: string + evaluatorFailures: number + achievedAt?: string + clearedAt?: string + pausedAt?: string + resumedAt?: string +} + +export type GoalEvaluatorDecision = { + complete: boolean + confidence: number + decision: GoalDecision + reason: string + nextInstruction: string | null + raw?: string +} diff --git a/src/state/AppStateStore.ts b/src/state/AppStateStore.ts index 580363ed7..c2bbf747d 100644 --- a/src/state/AppStateStore.ts +++ b/src/state/AppStateStore.ts @@ -38,6 +38,7 @@ import { getInitialSettings } from '../utils/settings/settings.js' import type { SettingsJson } from '../utils/settings/types.js' import { shouldEnableThinkingByDefault } from '../utils/thinking.js' import type { Store } from './store.js' +import type { GoalState } from '../services/goal/types.js' export type CompletionBoundary = | { type: 'complete'; completedAt: number; outputTokens: number } @@ -424,6 +425,8 @@ export type AppState = DeepImmutable<{ activeOverlays: ReadonlySet // Fast mode fastMode?: boolean + // Session-scoped auto-continuation goal. + goal: GoalState | null // Advisor model for server-side advisor tool (undefined = disabled). advisorModel?: string // Effort value @@ -563,5 +566,6 @@ export function getDefaultAppState(): AppState { effortValue: undefined, activeOverlays: new Set(), fastMode: false, + goal: null, } } diff --git a/src/test/fixtures/queryEngineGoalStatus.fixture.ts b/src/test/fixtures/queryEngineGoalStatus.fixture.ts new file mode 100644 index 000000000..7c12ab4c9 --- /dev/null +++ b/src/test/fixtures/queryEngineGoalStatus.fixture.ts @@ -0,0 +1,101 @@ +import { mock } from 'bun:test' +import assert from 'node:assert/strict' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +const fixtureCwd = mkdtempSync(join(tmpdir(), 'openclaude-query-engine-goal-')) +const originalMacro = (globalThis as Record).MACRO +const originalNodeEnv = process.env.NODE_ENV +const originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY + +try { + process.env.NODE_ENV = 'test' + process.env.ANTHROPIC_API_KEY = 'test-api-key' + mock.module('src/entrypoints/agentSdkTypes.js', () => ({ + EXIT_REASONS: [], + HOOK_EVENTS: [], + })) + ;(globalThis as Record).MACRO = { + BUILD_TIME: '2026-06-08T00:00:00.000Z', + DISPLAY_VERSION: 'test-version', + ISSUES_EXPLAINER: '', + NATIVE_PACKAGE_URL: undefined, + PACKAGE_URL: '', + VERSION: 'test-version', + } + + const { setSessionPersistenceDisabled } = await import( + '../../bootstrap/state.js' + ) + const { QueryEngine } = await import('../../QueryEngine.js') + const { createSystemMessage } = await import('../../utils/messages.js') + const { getDefaultAppState } = await import( + '../../state/AppStateStore.js' + ) + + setSessionPersistenceDisabled(true) + + let appState = getDefaultAppState() + const engine = new QueryEngine({ + cwd: fixtureCwd, + tools: [], + commands: [], + mcpClients: [], + agents: [], + canUseTool: async () => ({ behavior: 'allow' }), + getAppState: () => appState, + setAppState: updater => { + appState = updater(appState) + }, + readFileCache: {}, + thinkingConfig: { type: 'disabled' }, + customSystemPrompt: 'test system prompt', + query: async function* () { + yield createSystemMessage('Goal achieved: tests pass', 'info') + }, + } as never) + + const emitted: unknown[] = [] + for await (const message of engine.submitMessage('hello')) { + emitted.push(message) + } + + const goalStatusMessage = emitted.find( + (message): message is { + type: 'assistant' + message: { content: Array<{ type: string; text: string }> } + parent_tool_use_id: null + } => + typeof message === 'object' && + message !== null && + (message as { type?: unknown }).type === 'assistant' && + (message as { message?: { content?: unknown } }).message?.content instanceof + Array && + (message as { message: { content: Array<{ type?: unknown; text?: unknown }> } }) + .message.content[0]?.type === 'text' && + (message as { message: { content: Array<{ type?: unknown; text?: unknown }> } }) + .message.content[0]?.text === 'Goal achieved: tests pass', + ) + + assert.ok(goalStatusMessage) + assert.equal(goalStatusMessage.parent_tool_use_id, null) +} finally { + mock.restore() + if (originalMacro === undefined) { + delete (globalThis as Record).MACRO + } else { + ;(globalThis as Record).MACRO = originalMacro + } + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + if (originalAnthropicApiKey === undefined) { + delete process.env.ANTHROPIC_API_KEY + } else { + process.env.ANTHROPIC_API_KEY = originalAnthropicApiKey + } + rmSync(fixtureCwd, { recursive: true, force: true }) +} diff --git a/src/tools/AgentTool/AgentTool.teammateModel.test.ts b/src/tools/AgentTool/AgentTool.teammateModel.test.ts index 7f2ba4eaa..bc002302a 100644 --- a/src/tools/AgentTool/AgentTool.teammateModel.test.ts +++ b/src/tools/AgentTool/AgentTool.teammateModel.test.ts @@ -10,14 +10,11 @@ import type { AgentDefinition } from './loadAgentsDir.js' type ModelAllowlistModule = typeof import('../../utils/model/modelAllowlist.js') type SettingsModule = typeof import('../../utils/settings/settings.js') type SpawnMultiAgentModule = typeof import('../shared/spawnMultiAgent.js') -type AgentSwarmsEnabledModule = - typeof import('../../utils/agentSwarmsEnabled.js') type SpawnTeammateConfig = Parameters[0] let originalModelAllowlistModule: ModelAllowlistModule | undefined let originalSettingsModule: SettingsModule | undefined let originalSpawnMultiAgentModule: SpawnMultiAgentModule | undefined -let originalAgentSwarmsEnabledModule: AgentSwarmsEnabledModule | undefined let settingsForTest: SettingsJson = {} let allowedModelsForTest = new Set(['allowed-model']) @@ -55,12 +52,6 @@ afterEach(async () => { () => originalSpawnMultiAgentModule!, ) } - if (originalAgentSwarmsEnabledModule) { - mock.module( - '../../utils/agentSwarmsEnabled.js', - () => originalAgentSwarmsEnabledModule!, - ) - } restoreEnv('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS') restoreEnv('CLAUDE_CODE_SUBAGENT_MODEL') settingsForTest = {} @@ -97,12 +88,6 @@ async function importActualSpawnMultiAgent(): Promise { ) } -async function importActualAgentSwarmsEnabled(): Promise { - return import( - `../../utils/agentSwarmsEnabled.ts?agentToolActual=${Date.now()}-${Math.random()}` - ) -} - async function importAgentToolWithSpawnMock(): Promise<{ AgentTool: typeof import('./AgentTool.js').AgentTool spawnTeammate: ReturnType @@ -110,7 +95,6 @@ async function importAgentToolWithSpawnMock(): Promise<{ originalModelAllowlistModule ??= await importActualModelAllowlist() originalSettingsModule ??= await importActualSettings() originalSpawnMultiAgentModule ??= await importActualSpawnMultiAgent() - originalAgentSwarmsEnabledModule ??= await importActualAgentSwarmsEnabled() const spawnTeammate = mock(async () => ({ data: { teammate_id: 'teammate-1', @@ -133,10 +117,6 @@ async function importAgentToolWithSpawnMock(): Promise<{ ...originalSpawnMultiAgentModule!, spawnTeammate, })) - mock.module('../../utils/agentSwarmsEnabled.js', () => ({ - ...originalAgentSwarmsEnabledModule!, - isAgentSwarmsEnabled: () => true, - })) const { AgentTool } = await import( `./AgentTool.js?teammateModel=${Date.now()}-${Math.random()}` diff --git a/src/types/command.ts b/src/types/command.ts index e621ee239..6a40a849d 100644 --- a/src/types/command.ts +++ b/src/types/command.ts @@ -15,13 +15,27 @@ import type { Message } from './message.js' import type { PluginManifest } from './plugin.js' export type LocalCommandResult = - | { type: 'text'; value: string; display?: 'skip' } + | { + type: 'text' + value: string + display?: 'skip' + shouldQuery?: boolean + metaMessages?: string[] + nextInput?: string + submitNextInput?: boolean + } | { type: 'compact' compactionResult: CompactionResult displayText?: string + nextInput?: string + submitNextInput?: boolean } - | { type: 'skip' } // Skip messages + | { + type: 'skip' + nextInput?: string + submitNextInput?: boolean + } // Skip messages export type PromptCommand = { type: 'prompt' diff --git a/src/types/logs.ts b/src/types/logs.ts index 151593261..2728a9d8b 100644 --- a/src/types/logs.ts +++ b/src/types/logs.ts @@ -4,6 +4,7 @@ import type { ContentReplacementRecord } from 'src/utils/toolResultStorage.js' import type { AgentId } from './ids.js' import type { Message } from './message.js' import type { QueueOperationMessage } from './messageQueueTypes.js' +import type { GoalState } from '../services/goal/types.js' export type SerializedMessage = Message & { cwd: string @@ -50,6 +51,7 @@ export type LogOption = { mode?: 'coordinator' | 'normal' // Session mode for coordinator/normal detection worktreeSession?: PersistedWorktreeSession | null // Worktree state at session end (null = exited, undefined = never entered) contentReplacements?: ContentReplacementRecord[] // Replacement decisions for resume reconstruction + goal?: GoalState | null // Last session goal state, if any } export type SummaryMessage = { @@ -185,6 +187,12 @@ export type ContentReplacementEntry = { replacements: ContentReplacementRecord[] } +export type GoalStateEntry = { + type: 'goal-state' + sessionId: UUID + goal: GoalState | null +} + export type FileHistorySnapshotMessage = { type: 'file-history-snapshot' messageId: UUID @@ -313,6 +321,7 @@ export type Entry = | ModeEntry | WorktreeStateEntry | ContentReplacementEntry + | GoalStateEntry | ContextCollapseCommitEntry | ContextCollapseSnapshotEntry diff --git a/src/utils/apiPreconnect.test.ts b/src/utils/apiPreconnect.test.ts index 6741a2b26..7db4bcc43 100644 --- a/src/utils/apiPreconnect.test.ts +++ b/src/utils/apiPreconnect.test.ts @@ -3,11 +3,24 @@ import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' +import * as actualProviders from './model/providers.js' const originalEnv = { ...process.env } const originalFetch = globalThis.fetch +function getMockApiProvider() { + if (process.env.CLAUDE_CODE_USE_OPENAI === '1') return 'openai' + if (process.env.CLAUDE_CODE_USE_GEMINI === '1') return 'gemini' + if (process.env.CLAUDE_CODE_USE_GITHUB === '1') return 'github' + return 'firstParty' +} + async function importFreshModule() { + mock.restore() + mock.module('./model/providers.js', () => ({ + ...actualProviders, + getAPIProvider: getMockApiProvider, + })) return import(`./apiPreconnect.ts?ts=${Date.now()}-${Math.random()}`) } @@ -21,6 +34,7 @@ afterEach(() => { process.env = { ...originalEnv } globalThis.fetch = originalFetch mock.restore() + mock.module('./model/providers.js', () => actualProviders) } finally { releaseSharedMutationLock() } @@ -88,6 +102,10 @@ describe('preconnectAnthropicApi', () => { delete process.env.CLAUDE_CODE_CLIENT_CERT delete process.env.CLAUDE_CODE_CLIENT_KEY + mock.module('./model/providers.js', () => ({ + ...actualProviders, + getAPIProvider: () => 'firstParty', + })) const fetchMock = mock(() => Promise.resolve(new Response(null, { status: 200 }))) globalThis.fetch = fetchMock as typeof globalThis.fetch diff --git a/src/utils/attribution.test.ts b/src/utils/attribution.test.ts index 0700129dd..44f7ccb45 100644 --- a/src/utils/attribution.test.ts +++ b/src/utils/attribution.test.ts @@ -28,15 +28,16 @@ let getEnhancedPRAttribution: (typeof import('./attribution.js'))[ let testSettings: SettingsJson = {} const originalEnv = { - CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, + ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, + CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: + process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, + CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, + CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, CLAUDE_CODE_USE_GEMINI: process.env.CLAUDE_CODE_USE_GEMINI, CLAUDE_CODE_USE_GITHUB: process.env.CLAUDE_CODE_USE_GITHUB, CLAUDE_CODE_USE_MISTRAL: process.env.CLAUDE_CODE_USE_MISTRAL, - CLAUDE_CODE_USE_BEDROCK: process.env.CLAUDE_CODE_USE_BEDROCK, + CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI, CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX, - CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY, - CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED: - process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED, CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID: process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID, NVIDIA_NIM: process.env.NVIDIA_NIM, @@ -44,7 +45,6 @@ const originalEnv = { OPENAI_BASE_URL: process.env.OPENAI_BASE_URL, OPENAI_API_BASE: process.env.OPENAI_API_BASE, OPENAI_API_KEY: process.env.OPENAI_API_KEY, - ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL, ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL, ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL, diff --git a/src/utils/conversationRecovery.test.ts b/src/utils/conversationRecovery.test.ts index 1c983ff78..0b566a1bc 100644 --- a/src/utils/conversationRecovery.test.ts +++ b/src/utils/conversationRecovery.test.ts @@ -55,6 +55,20 @@ function user(uuid: string, content: string) { } } +function activeGoal(condition = 'resume goal') { + return { + id: id(900), + condition, + status: 'active', + createdAt: ts, + updatedAt: ts, + startedAt: ts, + turnCount: 1, + maxTurns: 50, + evaluatorFailures: 0, + } +} + async function writeJsonl(entry: unknown): Promise { const dir = await mkdtemp(join(tmpdir(), 'openclaude-conversation-recovery-')) tempDirs.push(dir) @@ -63,6 +77,14 @@ async function writeJsonl(entry: unknown): Promise { return filePath } +async function writeJsonlEntries(entries: unknown[]): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-conversation-recovery-')) + tempDirs.push(dir) + const filePath = join(dir, 'resume.jsonl') + await writeFile(filePath, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n') + return filePath +} + beforeEach(async () => { await acquireSharedMutationLock('utils/conversationRecovery.test.ts') }) @@ -126,6 +148,48 @@ test('loadConversationForResume accepts a small transcript from jsonl path', asy expect(result?.messages.length).toBeGreaterThan(0) }) +test('loadConversationForResume preserves goal metadata from a loaded log option', async () => { + process.env.CLAUDE_CODE_SIMPLE = '1' + const goal = activeGoal('keep going after resume') + const { loadConversationForResume } = await importFreshConversationRecovery() + + const result = await loadConversationForResume( + { + date: ts, + messages: [user(id(10), 'hello')], + value: 0, + created: new Date(ts), + modified: new Date(ts), + firstPrompt: 'hello', + messageCount: 1, + isSidechain: false, + sessionId, + goal, + } as any, + undefined, + ) + + expect(result?.goal).toEqual(goal) +}) + +test('loadConversationForResume preserves goal metadata from jsonl transcript path', async () => { + process.env.CLAUDE_CODE_SIMPLE = '1' + const goal = activeGoal('keep going after jsonl resume') + const path = await writeJsonlEntries([ + { + type: 'goal-state', + sessionId, + goal, + }, + user(id(11), 'hello'), + ]) + const { loadConversationForResume } = await importFreshConversationRecovery() + + const result = await loadConversationForResume('fixture', path) + + expect(result?.goal).toEqual(goal) +}) + test('loadConversationForResume rejects oversized reconstructed transcripts', async () => { process.env.CLAUDE_CODE_SIMPLE = '1' const hugeContent = 'x'.repeat(8 * 1024 * 1024 + 32 * 1024) diff --git a/src/utils/conversationRecovery.ts b/src/utils/conversationRecovery.ts index c37e29e24..399e26ee1 100644 --- a/src/utils/conversationRecovery.ts +++ b/src/utils/conversationRecovery.ts @@ -519,8 +519,13 @@ export function restoreSkillStateFromMessages(messages: Message[]): void { export async function loadMessagesFromJsonlPath(path: string): Promise<{ messages: SerializedMessage[] sessionId: UUID | undefined + goal: LogOption['goal'] | undefined }> { - const { messages: byUuid, leafUuids } = await loadTranscriptFile(path) + const { + messages: byUuid, + goalStates, + leafUuids, + } = await loadTranscriptFile(path) let tip: (typeof byUuid extends Map ? T : never) | null = null let tipTs = 0 for (const m of byUuid.values()) { @@ -531,14 +536,16 @@ export async function loadMessagesFromJsonlPath(path: string): Promise<{ tip = m } } - if (!tip) return { messages: [], sessionId: undefined } + if (!tip) return { messages: [], sessionId: undefined, goal: undefined } const chain = buildConversationChain(byUuid, tip) + const sessionId = tip.sessionId as UUID | undefined return { messages: removeExtraFields(chain), // Leaf's sessionId — forked sessions copy chain[0] from the source // transcript, so the root retains the source session's ID. Matches // loadFullLog's mostRecentLeaf.sessionId. - sessionId: tip.sessionId as UUID | undefined, + sessionId, + goal: sessionId ? goalStates.get(sessionId) : undefined, } } @@ -579,6 +586,7 @@ export async function loadConversationForResume( prNumber?: number prUrl?: string prRepository?: string + goal?: LogOption['goal'] // Full path to the session file (for cross-directory resume) fullPath?: string } | null> { @@ -586,6 +594,7 @@ export async function loadConversationForResume( let log: LogOption | null = null let messages: Message[] | null = null let sessionId: UUID | undefined + let goal: LogOption['goal'] | undefined if (source === undefined) { // --continue: most recent session, skipping live --bg/daemon sessions @@ -620,6 +629,7 @@ export async function loadConversationForResume( const loaded = await loadMessagesFromJsonlPath(sourceJsonlFile) messages = loaded.messages sessionId = loaded.sessionId + goal = loaded.goal } else if (typeof source === 'string') { // Load specific session by ID log = await getLastSessionLog(source as UUID) @@ -643,6 +653,7 @@ export async function loadConversationForResume( if (!sessionId) { sessionId = getSessionIdFromLog(log) as UUID } + goal = log.goal // Pass the original session ID to ensure the plan slug is associated with // the session we're resuming, not the temporary session ID before resume if (sessionId) { @@ -695,6 +706,7 @@ export async function loadConversationForResume( prNumber: log?.prNumber, prUrl: log?.prUrl, prRepository: log?.prRepository, + goal, // Include full path for cross-directory resume fullPath: log?.fullPath, } diff --git a/src/utils/fastMode.test.ts b/src/utils/fastMode.test.ts index f588d81c4..c59135680 100644 --- a/src/utils/fastMode.test.ts +++ b/src/utils/fastMode.test.ts @@ -1,8 +1,13 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test' +import * as realAxios from 'axios' +import * as realOauthConstants from 'src/constants/oauth.js' +import * as realGrowthbook from 'src/services/analytics/growthbook.js' import { acquireSharedMutationLock, releaseSharedMutationLock, } from '../test/sharedMutationLock.js' +import * as realAuth from './auth.js' +import * as realModel from './model/model.js' type ProvidersModule = typeof import('./model/providers.js') type AxiosModule = typeof import('axios') @@ -223,9 +228,11 @@ afterEach(async () => { if (originalProvidersModule) { mock.module('./model/providers.js', () => originalProvidersModule!) } - if (originalAxiosModule) { - mock.module('axios', () => originalAxiosModule!) - } + mock.module('axios', () => originalAxiosModule ?? realAxios) + mock.module('src/constants/oauth.js', () => realOauthConstants) + mock.module('src/services/analytics/growthbook.js', () => realGrowthbook) + mock.module('./auth.js', () => realAuth) + mock.module('./model/model.js', () => realModel) process.env = { ...originalEnv } const { resetStateForTests } = await import('../bootstrap/state.js') resetStateForTests() diff --git a/src/utils/processUserInput/processSlashCommand.goal.test.tsx b/src/utils/processUserInput/processSlashCommand.goal.test.tsx new file mode 100644 index 000000000..88e58d1db --- /dev/null +++ b/src/utils/processUserInput/processSlashCommand.goal.test.tsx @@ -0,0 +1,50 @@ +import { describe, expect, test } from 'bun:test' + +import goal from '../../commands/goal/index.js' +import { getDefaultAppState, type AppState } from '../../state/AppStateStore.js' +import { processSlashCommand } from './processSlashCommand.js' + +function makeContext() { + let state: AppState = getDefaultAppState() + return { + context: { + options: { + commands: [goal], + isNonInteractiveSession: false, + }, + getAppState: () => state, + setAppState: (updater: (prev: AppState) => AppState) => { + state = updater(state) + }, + messages: [], + } as any, + getState: () => state, + } +} + +describe('/goal slash-command plumbing', () => { + test('/goal inserts hidden directive and starts a query', async () => { + const { context, getState } = makeContext() + + const result = await processSlashCommand( + '/goal finish implementation', + [], + [], + [], + context, + () => {}, + ) + + expect(result.shouldQuery).toBe(true) + expect(getState().goal?.condition).toBe('finish implementation') + expect( + result.messages.some( + message => + message.type === 'user' && + message.isMeta === true && + typeof message.message.content === 'string' && + message.message.content.includes('A session goal has been set.'), + ), + ).toBe(true) + }) +}) diff --git a/src/utils/processUserInput/processSlashCommand.tsx b/src/utils/processUserInput/processSlashCommand.tsx index a0e24a0f6..c83003151 100644 --- a/src/utils/processUserInput/processSlashCommand.tsx +++ b/src/utils/processUserInput/processSlashCommand.tsx @@ -674,7 +674,9 @@ async function getMessagesForSlashCommand(commandName: string, args: string, set return { messages: [], shouldQuery: false, - command + command, + nextInput: result.nextInput, + submitNextInput: result.submitNextInput, }; } @@ -701,9 +703,13 @@ async function getMessagesForSlashCommand(commandName: string, args: string, set // (UUIDs never repeat, so they're never looked up). resetMicrocompactState(); return { - messages: buildPostCompactMessages(compactionResultWithSlashMessages), + messages: buildPostCompactMessages( + compactionResultWithSlashMessages, + ), shouldQuery: false, - command + command, + nextInput: result.nextInput, + submitNextInput: result.submitNextInput, }; } @@ -713,15 +719,32 @@ async function getMessagesForSlashCommand(commandName: string, args: string, set messages: [], shouldQuery: false, command, - resultText: result.value + resultText: result.value, + nextInput: result.nextInput, + submitNextInput: result.submitNextInput, }; } + const metaMessages = (result.metaMessages ?? []).map( + (content: string) => + createUserMessage({ + content, + isMeta: true, + }), + ); return { - messages: [userMessage, createCommandInputMessage(`${result.value}`)], - shouldQuery: false, + messages: [ + userMessage, + createCommandInputMessage( + `${result.value}`, + ), + ...metaMessages, + ], + shouldQuery: result.shouldQuery ?? false, command, - resultText: result.value + resultText: result.value, + nextInput: result.nextInput, + submitNextInput: result.submitNextInput, }; } catch (e) { logError(e); diff --git a/src/utils/sessionRestore.goal.test.ts b/src/utils/sessionRestore.goal.test.ts new file mode 100644 index 000000000..0e31b7883 --- /dev/null +++ b/src/utils/sessionRestore.goal.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from 'bun:test' + +import { + achieveGoal, + createGoalState, + markGoalEvaluated, + pauseGoal, +} from '../services/goal/state.js' +import type { GoalState } from '../services/goal/types.js' +import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' +import { + processResumedConversation, + restoreSessionStateFromLog, +} from './sessionRestore.js' + +describe('session restore goal lifecycle', () => { + test('processResumedConversation restores active goal into initial state', async () => { + const goal = markGoalEvaluated(createGoalState('finish after resume'), { + evaluatedMessageUuid: 'assistant-1', + decision: 'incomplete', + reason: 'more files remain', + }) + + const result = await processResumedConversation( + { + messages: [], + sessionId: '00000000-0000-4000-8000-000000001234', + goal, + }, + { + forkSession: true, + }, + { + modeApi: null, + mainThreadAgentDefinition: undefined, + agentDefinitions: { activeAgents: [], allAgents: [] }, + currentCwd: '/tmp', + cliAgents: [], + initialState: getDefaultAppState(), + }, + ) + + expect(result.initialState.goal?.status).toBe('active') + expect(result.initialState.goal?.condition).toBe('finish after resume') + expect(result.initialState.goal?.turnCount).toBe(0) + expect(result.initialState.goal?.lastEvaluatedMessageUuid).toBeUndefined() + }) + + test('processResumedConversation clears stale goal when resumed session has none', async () => { + const staleGoal = createGoalState('stale previous session goal') + + const result = await processResumedConversation( + { + messages: [], + sessionId: '00000000-0000-4000-8000-000000001235', + }, + { + forkSession: true, + }, + { + modeApi: null, + mainThreadAgentDefinition: undefined, + agentDefinitions: { activeAgents: [], allAgents: [] }, + currentCwd: '/tmp', + cliAgents: [], + initialState: { + ...getDefaultAppState(), + goal: staleGoal, + }, + }, + ) + + expect(result.initialState.goal).toBeNull() + }) + + test('processResumedConversation preserves inactive resumed goals unchanged', async () => { + const pausedGoal = pauseGoal(createGoalState('paused resume goal')) + const achievedGoal = achieveGoal(createGoalState('achieved resume goal'), { + evaluatedMessageUuid: 'assistant-achieved', + reason: 'done', + }) + const clearedGoal: GoalState = { + ...createGoalState('cleared resume goal'), + status: 'cleared', + clearedAt: '2026-04-02T00:00:00.000Z', + } + + for (const goal of [pausedGoal, achievedGoal, clearedGoal]) { + const result = await processResumedConversation( + { + messages: [], + sessionId: '00000000-0000-4000-8000-000000001236', + goal, + }, + { + forkSession: true, + }, + { + modeApi: null, + mainThreadAgentDefinition: undefined, + agentDefinitions: { activeAgents: [], allAgents: [] }, + currentCwd: '/tmp', + cliAgents: [], + initialState: getDefaultAppState(), + }, + ) + + expect(result.initialState.goal).toBe(goal) + } + }) + + test('restoreSessionStateFromLog clears stale in-memory goal when resumed session has none', () => { + const staleGoal = createGoalState('stale interactive resume goal') + let state: AppState = { + ...getDefaultAppState(), + goal: staleGoal, + } + + restoreSessionStateFromLog({}, update => { + state = update(state) + }) + + expect(state.goal).toBeNull() + }) + + test('restoreSessionStateFromLog preserves inactive resumed goals unchanged', () => { + const pausedGoal = pauseGoal(createGoalState('paused interactive goal')) + const achievedGoal = achieveGoal(createGoalState('achieved interactive goal'), { + evaluatedMessageUuid: 'assistant-achieved', + reason: 'done', + }) + const clearedGoal: GoalState = { + ...createGoalState('cleared interactive goal'), + status: 'cleared', + clearedAt: '2026-04-02T00:00:00.000Z', + } + + for (const goal of [pausedGoal, achievedGoal, clearedGoal]) { + let state: AppState = { + ...getDefaultAppState(), + goal: createGoalState('stale interactive goal'), + } + + restoreSessionStateFromLog({ goal }, update => { + state = update(state) + }) + + expect(state.goal).toBe(goal) + } + }) +}) diff --git a/src/utils/sessionRestore.ts b/src/utils/sessionRestore.ts index 2b941cbcb..4ad86ceea 100644 --- a/src/utils/sessionRestore.ts +++ b/src/utils/sessionRestore.ts @@ -52,6 +52,8 @@ import { saveMode, saveWorktreeState, } from './sessionStorage.js' +import { prepareGoalForSessionResume } from '../services/goal/state.js' +import type { GoalState } from '../services/goal/types.js' import { isTodoV2Enabled } from './tasks.js' import type { TodoList } from './todo/types.js' import { TodoListSchema } from './todo/types.js' @@ -67,6 +69,7 @@ type ResumeResult = { attributionSnapshots?: AttributionSnapshotMessage[] contextCollapseCommits?: ContextCollapseCommitEntry[] contextCollapseSnapshot?: ContextCollapseSnapshotEntry + goal?: GoalState | null } /** @@ -147,6 +150,9 @@ export function restoreSessionStateFromLog( })) } } + + const goal = prepareGoalForSessionResume(result.goal ?? null) + setAppState(prev => ({ ...prev, goal })) } /** @@ -312,6 +318,7 @@ type ResumeLoadResult = { prNumber?: number prUrl?: string prRepository?: string + goal?: GoalState | null } /** @@ -545,6 +552,7 @@ export async function processResumedConversation( ...(resumedAgentType && { agent: resumedAgentType }), ...(restoredAttribution && { attribution: restoredAttribution }), ...(standaloneAgentContext && { standaloneAgentContext }), + goal: prepareGoalForSessionResume(result.goal ?? null), agentDefinitions: refreshedAgentDefs, }, } diff --git a/src/utils/sessionStorage.test.ts b/src/utils/sessionStorage.test.ts index 63bfb98c6..259c652d0 100644 --- a/src/utils/sessionStorage.test.ts +++ b/src/utils/sessionStorage.test.ts @@ -1,14 +1,23 @@ import { afterEach, expect, test } from 'bun:test' import { type UUID } from 'node:crypto' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { + adoptResumedSessionFile, buildConversationChain, loadTranscriptFile, + recordGoalState, + resetProjectForTesting, + resetSessionFilePointer, + setSessionFileForTesting, + restoreSessionMetadata, stripPersistedToolUseResultsFromJSONLBuffer, } from './sessionStorage.ts' +import { createGoalState } from '../services/goal/state.js' +import { getSessionId, switchSession } from '../bootstrap/state.js' +import type { GoalState } from '../services/goal/types.js' const tempDirs: string[] = [] const sessionId = '00000000-0000-4000-8000-000000000999' @@ -112,6 +121,38 @@ async function writeJsonl(entries: unknown[]): Promise { return filePath } +function readGoalStateEntries(text: string): Array<{ goal: GoalState | null }> { + return text + .split('\n') + .filter(Boolean) + .map( + line => + JSON.parse(line) as { type?: string; goal?: GoalState | null }, + ) + .filter( + (entry): entry is { goal: GoalState | null } => + entry.type === 'goal-state', + ) +} + +async function withSessionPersistence(fn: () => Promise): Promise { + const originalPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE + const originalSessionId = getSessionId() + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + try { + resetProjectForTesting() + return await fn() + } finally { + if (originalPersistence === undefined) { + delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + } else { + process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalPersistence + } + switchSession(originalSessionId) + resetProjectForTesting() + } +} + afterEach(async () => { await Promise.all(tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))) }) @@ -318,3 +359,174 @@ test('loadTranscriptFile omits raw toolUseResult for persisted-output transcript (loaded?.message.content as Array<{ content: string }>)[0]?.content, ).toContain('Preview text') }) + +test('loadTranscriptFile restores last goal-state metadata entry', async () => { + const activeGoal = { + id: 'goal-1', + condition: 'finish implementation', + status: 'active', + createdAt: ts, + updatedAt: ts, + startedAt: ts, + turnCount: 2, + maxTurns: 50, + lastDecision: 'incomplete', + lastReason: 'tests not run', + evaluatorFailures: 0, + } + const filePath = await writeJsonl([ + { + type: 'goal-state', + sessionId, + goal: activeGoal, + }, + { + type: 'goal-state', + sessionId, + goal: { + ...activeGoal, + condition: 'finish build validation', + }, + }, + ]) + + const { goalStates } = await loadTranscriptFile(filePath) + + expect(goalStates.get(sessionId as never)?.condition).toBe( + 'finish build validation', + ) +}) + +test('loadTranscriptFile treats null goal-state as cleared', async () => { + const activeGoal = { + id: 'goal-1', + condition: 'finish implementation', + status: 'active', + createdAt: ts, + updatedAt: ts, + startedAt: ts, + turnCount: 0, + maxTurns: 50, + evaluatorFailures: 0, + } + const filePath = await writeJsonl([ + { + type: 'goal-state', + sessionId, + goal: activeGoal, + }, + { + type: 'goal-state', + sessionId, + goal: null, + }, + ]) + + const { goalStates } = await loadTranscriptFile(filePath) + + expect(goalStates.get(sessionId as never)).toBeNull() +}) + +test('restoreSessionMetadata clears cached goal when resumed transcript has no goal metadata', async () => { + await withSessionPersistence(async () => { + restoreSessionMetadata({ + goal: createGoalState('stale previous session goal', ts), + }) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) + tempDirs.push(dir) + const filePath = join(dir, `${sessionId}.jsonl`) + await writeFile( + filePath, + `${JSON.stringify(user(id(51), null, 'resume me'))}\n`, + ) + + switchSession(sessionId as never, dir) + await resetSessionFilePointer() + restoreSessionMetadata({}) + adoptResumedSessionFile() + + const text = await readFile(filePath, 'utf8') + expect(readGoalStateEntries(text)).toEqual([]) + }) +}) + +test('restoreSessionMetadata clears cached goal when resumed transcript has explicit null goal metadata', async () => { + await withSessionPersistence(async () => { + restoreSessionMetadata({ + goal: createGoalState('stale previous session goal', ts), + }) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) + tempDirs.push(dir) + const filePath = join(dir, `${sessionId}.jsonl`) + await writeFile( + filePath, + `${JSON.stringify(user(id(52), null, 'resume cleared goal'))}\n`, + ) + + switchSession(sessionId as never, dir) + await resetSessionFilePointer() + restoreSessionMetadata({ goal: null }) + adoptResumedSessionFile() + + const text = await readFile(filePath, 'utf8') + expect(readGoalStateEntries(text)).toEqual([]) + }) +}) + +test('restoreSessionMetadata re-appends the resumed active goal instead of stale cached goal', async () => { + await withSessionPersistence(async () => { + restoreSessionMetadata({ + goal: createGoalState('stale previous session goal', ts), + }) + const resumedGoal = createGoalState('resumed current goal', ts) + + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) + tempDirs.push(dir) + const filePath = join(dir, `${sessionId}.jsonl`) + await writeFile( + filePath, + `${JSON.stringify(user(id(53), null, 'resume active goal'))}\n`, + ) + + switchSession(sessionId as never, dir) + await resetSessionFilePointer() + restoreSessionMetadata({ goal: resumedGoal }) + adoptResumedSessionFile() + + const text = await readFile(filePath, 'utf8') + expect( + readGoalStateEntries(text).map(entry => entry.goal?.condition), + ).toEqual(['resumed current goal']) + }) +}) + +test('recordGoalState writes goal metadata durably before resolving', async () => { + await withSessionPersistence(async () => { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-storage-')) + tempDirs.push(dir) + const filePath = join(dir, `${sessionId}.jsonl`) + switchSession(sessionId as never) + setSessionFileForTesting(filePath) + + await recordGoalState( + { + id: 'goal-durable', + condition: 'durable goal', + status: 'active', + createdAt: ts, + updatedAt: ts, + startedAt: ts, + turnCount: 0, + maxTurns: 50, + evaluatorFailures: 0, + }, + sessionId as never, + ) + + const text = await readFile(filePath, 'utf8') + expect(text).toContain('"type":"goal-state"') + expect(text).toContain('durable goal') + }) +}) diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index ceb440279..33cf2aedf 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -30,7 +30,6 @@ import { isSessionPersistenceDisabled, switchSession, } from '../bootstrap/state.js' -import { builtInCommandNames } from '../commands.js' import { COMMAND_NAME_TAG, TICK_TAG } from '../constants/xml.js' import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' import * as sessionIngress from '../services/api/sessionIngress.js' @@ -48,6 +47,7 @@ import { type ContextCollapseSnapshotEntry, type Entry, type FileHistorySnapshotMessage, + type GoalStateEntry, type LogOption, type PersistedWorktreeSession, type SerializedMessage, @@ -98,6 +98,16 @@ import { validateUuid } from './uuid.js' // See: https://github.com/oven-sh/bun/issues/26168 const VERSION = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown' +let builtInCommandNamesCache: Set | undefined + +function getBuiltInCommandNames(): Set { + if (builtInCommandNamesCache) return builtInCommandNamesCache + const commands = + require('../commands.js') as typeof import('../commands.js') + builtInCommandNamesCache = commands.builtInCommandNames() + return builtInCommandNamesCache +} + type Transcript = ( | UserMessage | AssistantMessage @@ -541,6 +551,7 @@ class Project { currentSessionPrNumber: number | undefined currentSessionPrUrl: string | undefined currentSessionPrRepository: string | undefined + currentSessionGoal: GoalStateEntry['goal'] | undefined sessionFile: string | null = null // Entries buffered while sessionFile is null. Flushed by materializeSessionFile @@ -832,6 +843,17 @@ class Project { timestamp: new Date().toISOString(), }) } + if ( + this.currentSessionGoal && + (this.currentSessionGoal.status === 'active' || + this.currentSessionGoal.status === 'paused') + ) { + appendEntryToFile(this.sessionFile, { + type: 'goal-state', + sessionId, + goal: this.currentSessionGoal, + }) + } } async flush(): Promise { @@ -1121,6 +1143,20 @@ class Project { }) } + async insertGoalState(goal: GoalStateEntry['goal'], sessionId: UUID) { + return this.trackWrite(async () => { + const entry: GoalStateEntry = { + type: 'goal-state', + sessionId, + goal, + } + if (sessionId === getSessionId()) { + this.currentSessionGoal = goal ?? undefined + } + await this.appendEntry(entry, sessionId) + }) + } + async appendEntry(entry: Entry, sessionId: UUID = getSessionId() as UUID) { if (this.shouldSkipPersistence()) { return @@ -1201,6 +1237,8 @@ class Project { ? getAgentTranscriptPath(entry.agentId) : sessionFile void this.enqueueWrite(targetFile, entry) + } else if (entry.type === 'goal-state') { + await this.appendToFile(sessionFile, jsonStringify(entry) + '\n') } else if (entry.type === 'marble-origami-commit') { // Always append. Commit order matters for restore (later commits may // reference earlier commits' summary messages), so these must be @@ -1494,6 +1532,13 @@ export async function recordContentReplacement( await getProject().insertContentReplacement(replacements, agentId) } +export async function recordGoalState( + goal: GoalStateEntry['goal'], + sessionId: UUID = getSessionId() as UUID, +) { + await getProject().insertGoalState(goal, sessionId) +} + /** * Reset the session file pointer after switchSession/regenerateSessionId. * The new file is created lazily on the first user/assistant message. @@ -1774,7 +1819,7 @@ export function getFirstMeaningfulUserMessageTextContent( // If it's a built-in command, then it's unlikely to provide // meaningful context (e.g. `/model sonnet`) - if (builtInCommandNames().has(commandName)) { + if (getBuiltInCommandNames().has(commandName)) { continue } else { // Otherwise, for custom commands, then keep it only if it has @@ -2554,6 +2599,7 @@ export async function loadTranscriptFromFile( leafUuids, contentReplacements, worktreeStates, + goalStates, } = await loadTranscriptFile(filePath) if (messages.size === 0) { @@ -2599,6 +2645,7 @@ export async function loadTranscriptFromFile( worktreeSession: worktreeStates.has(sessionId) ? worktreeStates.get(sessionId) : undefined, + goal: goalStates.get(sessionId), } } @@ -3013,6 +3060,7 @@ export function restoreSessionMetadata(meta: { prNumber?: number prUrl?: string prRepository?: string + goal?: GoalStateEntry['goal'] }): void { const project = getProject() // ??= so --name (cacheSessionTitle) wins over the resumed @@ -3029,6 +3077,10 @@ export function restoreSessionMetadata(meta: { project.currentSessionPrNumber = meta.prNumber if (meta.prUrl) project.currentSessionPrUrl = meta.prUrl if (meta.prRepository) project.currentSessionPrRepository = meta.prRepository + // Unlike display-only metadata, absence of a goal-state entry means this + // resumed session has no goal. Clear any cached goal so adopt/re-append + // cannot persist a previous session's active goal into this transcript. + project.currentSessionGoal = meta.goal ?? undefined } /** @@ -3049,6 +3101,7 @@ export function clearSessionMetadata(): void { project.currentSessionPrNumber = undefined project.currentSessionPrUrl = undefined project.currentSessionPrRepository = undefined + project.currentSessionGoal = undefined } /** @@ -3222,6 +3275,7 @@ export async function loadFullLog(log: LogOption): Promise { fileHistorySnapshots, attributionSnapshots, contentReplacements, + goalStates, contextCollapseCommits, contextCollapseSnapshot, leafUuids, @@ -3285,6 +3339,7 @@ export async function loadFullLog(log: LogOption): Promise { contentReplacements: sessionId ? (contentReplacements.get(sessionId) ?? []) : log.contentReplacements, + goal: sessionId ? goalStates.get(sessionId) : log.goal, // Filter to the resumed session's entries. loadTranscriptFile reads // the file sequentially so the array is already in commit order; // filter preserves that. @@ -3367,6 +3422,7 @@ const METADATA_TYPE_MARKERS = [ '"type":"mode"', '"type":"worktree-state"', '"type":"pr-link"', + '"type":"goal-state"', ] const METADATA_MARKER_BUFS = METADATA_TYPE_MARKERS.map(m => Buffer.from(m)) // Longest marker is 22 bytes; +1 for leading `{` = 23. @@ -3736,6 +3792,7 @@ export async function loadTranscriptFile( attributionSnapshots: Map contentReplacements: Map agentContentReplacements: Map + goalStates: Map contextCollapseCommits: ContextCollapseCommitEntry[] contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined leafUuids: Set @@ -3759,6 +3816,7 @@ export async function loadTranscriptFile( AgentId, ContentReplacementRecord[] >() + const goalStates = new Map() // Array, not Map — commit order matters (nested collapses). const contextCollapseCommits: ContextCollapseCommitEntry[] = [] // Last-wins — later entries supersede. @@ -3858,6 +3916,8 @@ export async function loadTranscriptFile( prNumbers.set(entry.sessionId, entry.prNumber) prUrls.set(entry.sessionId, entry.prUrl) prRepositories.set(entry.sessionId, entry.prRepository) + } else if (entry.type === 'goal-state' && entry.sessionId) { + goalStates.set(entry.sessionId, entry.goal) } }) } @@ -3922,6 +3982,8 @@ export async function loadTranscriptFile( prNumbers.set(entry.sessionId, entry.prNumber) prUrls.set(entry.sessionId, entry.prUrl) prRepositories.set(entry.sessionId, entry.prRepository) + } else if (entry.type === 'goal-state' && entry.sessionId) { + goalStates.set(entry.sessionId, entry.goal) } else if (entry.type === 'file-history-snapshot') { fileHistorySnapshots.set(entry.messageId, entry) } else if (entry.type === 'attribution-snapshot') { @@ -4058,6 +4120,7 @@ export async function loadTranscriptFile( attributionSnapshots, contentReplacements, agentContentReplacements, + goalStates, contextCollapseCommits, contextCollapseSnapshot, leafUuids, @@ -4077,6 +4140,7 @@ async function loadSessionFile(sessionId: UUID): Promise<{ fileHistorySnapshots: Map attributionSnapshots: Map contentReplacements: Map + goalStates: Map contextCollapseCommits: ContextCollapseCommitEntry[] contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined }> { @@ -4132,6 +4196,7 @@ export async function getLastSessionLog( fileHistorySnapshots, attributionSnapshots, contentReplacements, + goalStates, contextCollapseCommits, contextCollapseSnapshot, } = await loadSessionFile(sessionId) @@ -4173,6 +4238,7 @@ export async function getLastSessionLog( contentReplacements.get(sessionId) ?? [], ), worktreeSession: worktreeStates.get(sessionId), + goal: goalStates.get(sessionId), contextCollapseCommits: contextCollapseCommits.filter( e => e.sessionId === sessionId, ), @@ -4866,6 +4932,7 @@ export async function loadAllLogsFromSessionFile( fileHistorySnapshots, attributionSnapshots, contentReplacements, + goalStates, leafUuids, } = await loadTranscriptFile(sessionFile, { keepAllLeaves: true }) @@ -4939,6 +5006,7 @@ export async function loadAllLogsFromSessionFile( chain, ), contentReplacements: contentReplacements.get(sessionId) ?? [], + goal: goalStates.get(sessionId), }) } @@ -5122,7 +5190,7 @@ function extractFirstPromptFromChunk(chunk: string): string { if (commandNameTag) { const name = commandNameTag.replace(/^\//, '') const commandArgs = extractTag(result, 'command-args')?.trim() || '' - if (builtInCommandNames().has(name) || !commandArgs) { + if (getBuiltInCommandNames().has(name) || !commandArgs) { if (!firstCommandFallback) { firstCommandFallback = commandNameTag }