feat(goal): add session-scoped /goal continuation (#1293)

* feat(goal): add persisted session goal state

Introduce the session-scoped goal state model, bounded evaluator, continuation controller, and transcript metadata persistence. Restore active goals on session resume while keeping achieved and cleared goals from auto-running.

* feat(goal): add slash command controls

Register /goal as a lazy local command with set, status, pause, resume, clear, and clear aliases. Route command-started continuations through hidden meta messages and make /clear clear active goal state.

* feat(goal): continue goals through stop hooks

Evaluate active goals once per terminal assistant turn after configured Stop hooks pass. Incomplete goals reuse the blocking-error continuation path; complete goals persist achieved status without spawning a parallel queue.

* test(goal): cover commands continuation and resume

Add focused coverage for command validation and aliases, state transitions, evaluator malformed-output handling, Stop-hook precedence, SDK/headless visibility, /clear lifecycle behavior, and durable resume persistence.

* fix(goal): fail closed on evaluator failures

* fix(goal): clear stale goal on resume

* Persist goal continuations before auto-resume

* test: isolate CI-sensitive state

* test: isolate attribution provider state

* test: tighten CI state isolation

* fix(goal): clear cached metadata on resume

* test(goal): harden resume metadata coverage

* test: clean up teammate model fixture merge

* fix(goal): align persistence session id type

* fix(goal): address status command review

* fix(goal): address follow-up review comments

* test(goal): isolate review regression coverage

* test(goal): make queryengine fixture ci-safe

* fix(goal): clarify resume message

* test: restore api preconnect provider mock

* fix(goal): address review feedback
This commit is contained in:
chioarub
2026-06-09 07:55:49 +08:00
committed by GitHub
parent 1d90960afa
commit 102cc3060f
41 changed files with 3557 additions and 64 deletions
+7 -2
View File
@@ -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
}
+4
View File
@@ -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<Command> = 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<Command> = new Set(
summary, // Summarize conversation
releaseNotes, // Show changelog
files, // List tracked files
goal, // Manage session goal continuation
].filter((c): c is Command => c !== null),
)
@@ -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)
})
})
+4
View File
@@ -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()
+213
View File
@@ -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<LocalCommandResult, { type: 'text' }>
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 <condition> 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)
}
})
})
+137
View File
@@ -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<LocalCommandCall>[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()
+12
View File
@@ -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
+3 -1
View File
@@ -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' },
+6
View File
@@ -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 {
+245
View File
@@ -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)
})
})
+233
View File
@@ -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<any, any>,
): 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.')
})
})
+108 -15
View File
@@ -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,
}
}
}
+83
View File
@@ -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: '',
})
})
})
+18 -1
View File
@@ -27,10 +27,12 @@ async function requestLoopback(
method?: string
headers?: Record<string, string>
body?: string
timeoutMs?: number
} = {},
): Promise<LoopbackResponse> {
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')
+525
View File
@@ -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<any, any>,
): 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([])
})
})
+252
View File
@@ -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<void> {
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<Message, Message[]> {
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,
}),
]
}
+193
View File
@@ -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([])
})
})
+279
View File
@@ -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<string>
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<string, unknown>
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<string, unknown>).type === 'text' &&
typeof (item as Record<string, unknown>).text === 'string'
? ((item as Record<string, unknown>).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, unknown>): 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, unknown>): 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<string, unknown>).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<string, unknown>
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<string, unknown>
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<GoalEvaluatorDecision> {
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,
}
}
}
+31
View File
@@ -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')
}
+14
View File
@@ -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<void> {
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)
}
+8
View File
@@ -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)
}
+136
View File
@@ -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)
})
})
+181
View File
@@ -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<GoalDecision, 'complete'>
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,
}
}
+18
View File
@@ -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<string, unknown>
return (
record.type === 'system' &&
record.subtype === 'informational' &&
typeof record.content === 'string' &&
isGoalStatusContent(record.content)
)
}
+32
View File
@@ -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
}
+4
View File
@@ -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<string>
// 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<string>(),
fastMode: false,
goal: null,
}
}
+101
View File
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).MACRO
} else {
;(globalThis as Record<string, unknown>).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 })
}
@@ -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<SpawnMultiAgentModule['spawnTeammate']>[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<SpawnMultiAgentModule> {
)
}
async function importActualAgentSwarmsEnabled(): Promise<AgentSwarmsEnabledModule> {
return import(
`../../utils/agentSwarmsEnabled.ts?agentToolActual=${Date.now()}-${Math.random()}`
)
}
async function importAgentToolWithSpawnMock(): Promise<{
AgentTool: typeof import('./AgentTool.js').AgentTool
spawnTeammate: ReturnType<typeof mock>
@@ -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()}`
+16 -2
View File
@@ -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'
+9
View File
@@ -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
+18
View File
@@ -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
+6 -6
View File
@@ -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,
+64
View File
@@ -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<string> {
const dir = await mkdtemp(join(tmpdir(), 'openclaude-conversation-recovery-'))
tempDirs.push(dir)
@@ -63,6 +77,14 @@ async function writeJsonl(entry: unknown): Promise<string> {
return filePath
}
async function writeJsonlEntries(entries: unknown[]): Promise<string> {
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)
+15 -3
View File
@@ -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<UUID, infer T> ? 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,
}
+10 -3
View File
@@ -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()
@@ -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 <condition> 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)
})
})
@@ -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(`<local-command-stdout>${result.value}</local-command-stdout>`)],
shouldQuery: false,
messages: [
userMessage,
createCommandInputMessage(
`<local-command-stdout>${result.value}</local-command-stdout>`,
),
...metaMessages,
],
shouldQuery: result.shouldQuery ?? false,
command,
resultText: result.value
resultText: result.value,
nextInput: result.nextInput,
submitNextInput: result.submitNextInput,
};
} catch (e) {
logError(e);
+151
View File
@@ -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)
}
})
})
+8
View File
@@ -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,
},
}
+213 -1
View File
@@ -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<string> {
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<T>(fn: () => Promise<T>): Promise<T> {
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')
})
})
+71 -3
View File
@@ -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<string> | undefined
function getBuiltInCommandNames(): Set<string> {
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<void> {
@@ -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<T extends Message>(
// 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<LogOption> {
fileHistorySnapshots,
attributionSnapshots,
contentReplacements,
goalStates,
contextCollapseCommits,
contextCollapseSnapshot,
leafUuids,
@@ -3285,6 +3339,7 @@ export async function loadFullLog(log: LogOption): Promise<LogOption> {
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<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
agentContentReplacements: Map<AgentId, ContentReplacementRecord[]>
goalStates: Map<UUID, GoalStateEntry['goal']>
contextCollapseCommits: ContextCollapseCommitEntry[]
contextCollapseSnapshot: ContextCollapseSnapshotEntry | undefined
leafUuids: Set<UUID>
@@ -3759,6 +3816,7 @@ export async function loadTranscriptFile(
AgentId,
ContentReplacementRecord[]
>()
const goalStates = new Map<UUID, GoalStateEntry['goal']>()
// 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<UUID, FileHistorySnapshotMessage>
attributionSnapshots: Map<UUID, AttributionSnapshotMessage>
contentReplacements: Map<UUID, ContentReplacementRecord[]>
goalStates: Map<UUID, GoalStateEntry['goal']>
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
}