Repository navigation
fix(repl): make local interactive max-turns configurable #2086
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
kevincodex1
merged 34 commits into
Twigpine:main
from
jatmn:fix/issue-2079-interactive-max-turns
Aug 6, 2026
Merged
Changes from all commits
Commits
Show all changes
34 commits
Select commit
Hold shift + click to select a range
8c6c9f4
fix(repl): make interactive max-turns configurable
jatmn 3c949f6
fix(repl): forward --max-turns on connect/ssh/remote launches
jatmn 607dade
fix(repl): scope interactive max-turns to local query loops
jatmn 40c2905
feat(config): add interactive max turns under /config
jatmn ef787da
docs(repl): clarify invalid OPENCLAUDE_MAX_TURNS precedence
jatmn 6f124b8
fix(repl): address PR review on max-turns help and web version gate
jatmn 05ecfba
fix(repl): typecheck Commander maxTurns opts and warn on invalid env
jatmn d7594da
fix(repl): warn when max turns is unlimited
jatmn 2908082
fix(repl): scope unlimited-turn warning locally
jatmn f7135e7
fix(repl): preserve interactive turn caps across backgrounding
jatmn 012eda9
fix(repl): preserve turn caps when backgrounding
jatmn 8eca6ad
fix(repl): share turn budget across background handoff
jatmn 3c7db93
fix(repl): reserve turns at provider dispatch
jatmn afbe89f
fix(repl): snapshot background handoff transcript
jatmn a789f19
fix(tasks): avoid phantom background session task
jatmn 3a43020
fix(repl): preserve handoff lifecycle state
jatmn 09e794f
fix(repl): own pending background handoffs
jatmn 14b1c8b
test(tasks): isolate background session task storage
jatmn ffe9c02
fix(repl): refresh background task title and test cleanup
jatmn b1f5930
test(repl): cover max-turn CLI dispatch paths
jatmn 94d714b
test(queue): cover prepend notification and priority
jatmn 4fa9c8d
fix(repl): skip background handoff after foreground query throws
jatmn 2978cb1
fix(repl): address PR review findings on notifications and handoff
jatmn f746034
fix(repl): resolve latest CodeRabbit inline review findings
jatmn fadcdc6
fix(repl): keep notification restore active until provider dispatch
jatmn 465bf47
test(repl): cover post-dispatch ownership and headless max-turns 0
jatmn 4191f3d
fix(repl): restore only embeddable notifications on Ctrl+B handoff abort
jatmn 2db82eb
fix(repl): address review findings on background handoff
jatmn 3a40063
fix(repl): guard deferred cap restore and skip remote turn limits
jatmn 926b2e7
fix(repl): use messagesRef for deferred cap transcript anchor
jatmn 351b604
test: harden context fallback warning assertion after max-turns tests
jatmn b62b1b5
test: address PR review findings on headless max-turns boundary
jatmn bb9a1b1
test: tidy headless max-turns boundary test and env isolation
jatmn b1ac0c8
fix: address PR review findings for max-turns background handoff
jatmn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,223 @@ | ||
| import { afterEach, describe, expect, spyOn, test } from 'bun:test' | ||
|
|
||
| import type { Command } from '../commands.js' | ||
| import type { SDKMessage } from '../entrypoints/agentSdkTypes.js' | ||
| import * as queryEngineModule from '../QueryEngine.js' | ||
| import * as queryModule from '../query.js' | ||
| import { type QueryParams } from '../query.js' | ||
| import type { QueryDeps } from '../query/deps.js' | ||
| import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' | ||
| import type { Tools } from '../Tool.js' | ||
| import { | ||
| createAssistantMessage, | ||
| createUserMessage, | ||
| } from '../utils/messages.js' | ||
| import * as processModule from '../utils/process.js' | ||
| import { asSystemPrompt } from '../utils/systemPromptType.js' | ||
| import { parseMaxTurnsCli, resolveReplMaxTurns } from '../utils/replMaxTurns.js' | ||
| import { runHeadless } from './print.js' | ||
|
|
||
| function makeHeadlessQueryParams(maxTurns: number | undefined): QueryParams { | ||
| return { | ||
| messages: [createUserMessage({ content: 'headless prompt' })], | ||
| systemPrompt: asSystemPrompt([]), | ||
| userContext: {}, | ||
| systemContext: {}, | ||
| canUseTool: async () => ({ behavior: 'allow' }), | ||
| maxTurns, | ||
| querySource: 'sdk', | ||
| toolUseContext: { | ||
| abortController: new AbortController(), | ||
| agentId: 'agent-test', | ||
| getAppState: () => ({ | ||
| fastMode: false, | ||
| mcp: { tools: [], clients: [] }, | ||
| toolPermissionContext: { | ||
| mode: 'default', | ||
| additionalWorkingDirectories: new Map(), | ||
| alwaysAllowRules: {}, | ||
| alwaysDenyRules: {}, | ||
| alwaysAskRules: {}, | ||
| isBypassPermissionsModeAvailable: false, | ||
| }, | ||
| sessionHooks: new Map(), | ||
| mainLoopModel: 'gpt-4o', | ||
| effortValue: undefined, | ||
| advisorModel: undefined, | ||
| }), | ||
| options: { | ||
| commands: [], | ||
| debug: false, | ||
| thinkingConfig: { type: 'disabled' }, | ||
| tools: [], | ||
| verbose: false, | ||
| mcpClients: [], | ||
| mcpResources: {}, | ||
| isNonInteractiveSession: true, | ||
| agentDefinitions: { activeAgents: [], allAgents: [] }, | ||
| appendSystemPrompt: undefined, | ||
| providerOverride: undefined, | ||
| mainLoopModel: 'gpt-4o', | ||
| }, | ||
| addNotification: () => {}, | ||
| messages: [], | ||
| setInProgressToolUseIDs: () => {}, | ||
| setResponseLength: () => {}, | ||
| updateAttributionState: () => {}, | ||
| } as unknown as QueryParams['toolUseContext'], | ||
| deps: { | ||
| callModel: async function* () { | ||
| yield createAssistantMessage({ content: 'done' }) | ||
| }, | ||
| microcompact: async messages => ({ messages }), | ||
| autocompact: async () => ({ | ||
| compactionResult: null, | ||
| consecutiveFailures: undefined, | ||
| }), | ||
| uuid: () => '00000000-0000-4000-8000-000000000000', | ||
| } as unknown as QueryDeps, | ||
| } | ||
| } | ||
|
|
||
| function createHeadlessSuccessResult(): SDKMessage { | ||
| return { | ||
| type: 'result', | ||
| subtype: 'success', | ||
| duration_ms: 0, | ||
| duration_api_ms: 0, | ||
| is_error: false, | ||
| num_turns: 1, | ||
| result: 'done', | ||
| stop_reason: 'end_turn', | ||
| total_cost_usd: 0, | ||
| usage: { | ||
| input_tokens: 0, | ||
| output_tokens: 0, | ||
| cache_creation_input_tokens: 0, | ||
| cache_read_input_tokens: 0, | ||
| }, | ||
| modelUsage: {}, | ||
| permission_denials: [], | ||
| uuid: '00000000-0000-4000-8000-000000000001', | ||
| session_id: 'test-session', | ||
| } | ||
| } | ||
|
|
||
| function createHeadlessRunOptions(maxTurns: number | undefined) { | ||
| return { | ||
| continue: undefined, | ||
| resume: undefined, | ||
| fromPr: undefined, | ||
| resumeSessionAt: undefined, | ||
| verbose: false, | ||
| outputFormat: 'text', | ||
| jsonSchema: undefined, | ||
| permissionPromptToolName: undefined, | ||
| allowedTools: undefined, | ||
| thinkingConfig: { type: 'disabled' as const }, | ||
| maxTurns, | ||
| maxBudgetUsd: undefined, | ||
| taskBudget: undefined, | ||
| systemPrompt: undefined, | ||
| appendSystemPrompt: undefined, | ||
| userSpecifiedModel: undefined, | ||
| fallbackModel: undefined, | ||
| teleport: undefined, | ||
| sdkUrl: undefined, | ||
| replayUserMessages: undefined, | ||
| includePartialMessages: undefined, | ||
| forkSession: undefined, | ||
| rewindFiles: undefined, | ||
| enableAuthStatus: undefined, | ||
| agent: undefined, | ||
| workload: undefined, | ||
| } | ||
| } | ||
|
|
||
| async function waitForAskCall( | ||
| askSpy: ReturnType<typeof spyOn<typeof queryEngineModule, 'ask'>>, | ||
| ): Promise<void> { | ||
| const deadline = Date.now() + 15_000 | ||
| while (askSpy.mock.calls.length === 0) { | ||
| if (Date.now() >= deadline) { | ||
| throw new Error('Timed out waiting for runHeadless to call ask()') | ||
| } | ||
| await new Promise(resolve => setTimeout(resolve, 25)) | ||
| } | ||
| } | ||
|
|
||
| describe('headless --print max-turns', () => { | ||
| const savedSimple = process.env.CLAUDE_CODE_SIMPLE | ||
|
|
||
| afterEach(() => { | ||
| if (savedSimple === undefined) { | ||
| delete process.env.CLAUDE_CODE_SIMPLE | ||
| } else { | ||
| process.env.CLAUDE_CODE_SIMPLE = savedSimple | ||
| } | ||
| }) | ||
|
|
||
| test('forwards parsed --max-turns 0 into query params without interactive resolution', async () => { | ||
| const headlessMaxTurns = parseMaxTurnsCli('0') | ||
| expect(headlessMaxTurns).toBe(0) | ||
| expect(resolveReplMaxTurns(headlessMaxTurns)).toBeUndefined() | ||
|
|
||
| const querySpy = spyOn(queryModule, 'query') | ||
| const params = makeHeadlessQueryParams(headlessMaxTurns) | ||
|
|
||
| const generator = queryModule.query(params) | ||
| let terminal | ||
| while (true) { | ||
| const next = await generator.next() | ||
| if (next.done) { | ||
| terminal = next.value | ||
| break | ||
| } | ||
| } | ||
|
|
||
| expect(querySpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) | ||
| expect(terminal?.reason).toBe('completed') | ||
| querySpy.mockRestore() | ||
| }) | ||
|
|
||
| test('forwards maxTurns 0 through runHeadless into ask()', async () => { | ||
| process.env.CLAUDE_CODE_SIMPLE = '1' | ||
|
|
||
| const stdoutSpy = spyOn(processModule, 'writeToStdout').mockImplementation( | ||
| () => {}, | ||
| ) | ||
| const askSpy = spyOn(queryEngineModule, 'ask').mockImplementation( | ||
| async function* () { | ||
| yield createHeadlessSuccessResult() | ||
| }, | ||
| ) | ||
|
|
||
| let state = getDefaultAppState() | ||
| const getAppState = () => state | ||
| const setAppState = (update: (previous: AppState) => AppState) => { | ||
| state = update(state) | ||
| } | ||
|
|
||
| let runPromise: Promise<void> | undefined | ||
| try { | ||
| runPromise = runHeadless( | ||
| 'headless prompt', | ||
| getAppState, | ||
| setAppState, | ||
| [] as Command[], | ||
| [] as Tools, | ||
| {}, | ||
| [], | ||
| createHeadlessRunOptions(0), | ||
| ) | ||
|
|
||
| await waitForAskCall(askSpy) | ||
| expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) | ||
| await runPromise | ||
| } finally { | ||
| await runPromise?.catch(() => {}) | ||
| askSpy.mockRestore() | ||
| stdoutSpy.mockRestore() | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }) | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.