Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
98105f4
feat(goal): add persisted session goal state
chioarub May 21, 2026
1672d5a
feat(goal): add slash command controls
chioarub May 21, 2026
6cc17b2
feat(goal): continue goals through stop hooks
chioarub May 21, 2026
3b242df
test(goal): cover commands continuation and resume
chioarub May 21, 2026
c5477b1
fix(goal): fail closed on evaluator failures
chioarub May 23, 2026
4c59f49
fix(goal): clear stale goal on resume
chioarub May 24, 2026
6cedc93
Persist goal continuations before auto-resume
chioarub May 25, 2026
e55050e
test: isolate CI-sensitive state
chioarub May 28, 2026
6d7cd02
test: isolate attribution provider state
chioarub May 28, 2026
a5c1b41
test: tighten CI state isolation
chioarub May 28, 2026
a392a3b
fix(goal): clear cached metadata on resume
chioarub May 28, 2026
b2c4449
test(goal): harden resume metadata coverage
chioarub May 29, 2026
758a1c5
test: clean up teammate model fixture merge
chioarub May 31, 2026
88bbf5b
fix(goal): align persistence session id type
chioarub Jun 2, 2026
00b6143
fix(goal): address status command review
chioarub Jun 8, 2026
4b3f46b
fix(goal): address follow-up review comments
chioarub Jun 8, 2026
389c886
test(goal): isolate review regression coverage
chioarub Jun 8, 2026
8c74971
test(goal): make queryengine fixture ci-safe
chioarub Jun 8, 2026
361979e
fix(goal): clarify resume message
chioarub Jun 8, 2026
1241272
test: restore api preconnect provider mock
chioarub Jun 8, 2026
d2966fc
fix(goal): address review feedback
chioarub Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/QueryEngine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -170,6 +171,7 @@ export type QueryEngineConfig = {
yieldedSystemMsg: Message,
store: Message[],
) => { messages: Message[]; executed: boolean } | undefined
query?: typeof defaultQuery
}

/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
}
Expand Down
4 changes: 4 additions & 0 deletions src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -325,6 +326,7 @@ const COMMANDS = memoize((): Command[] => [
theme,
logo,
feedback,
goal,
review,
ultrareview,
rewind,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
)

Expand Down
49 changes: 49 additions & 0 deletions src/commands/clear/conversation.goal.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

expect(state.goal).toBeNull()
expect(sawEmptyMessages).toBe(true)
})
})
4 changes: 4 additions & 0 deletions src/commands/clear/conversation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { processSessionStartHooks } from '../../utils/sessionStart.js'
import {
clearSessionMetadata,
getAgentTranscriptPath,
recordGoalState,
resetSessionFilePointer,
saveWorktreeState,
} from '../../utils/sessionStorage.js'
Expand Down Expand Up @@ -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()

Expand Down
213 changes: 213 additions & 0 deletions src/commands/goal/goal.test.ts
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading