mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 02:34:15 -05:00
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:
+7
-2
@@ -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,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)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
@@ -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
@@ -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' },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user