Skip to content
Merged
Show file tree
Hide file tree
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 Aug 1, 2026
3c949f6
fix(repl): forward --max-turns on connect/ssh/remote launches
jatmn Aug 1, 2026
607dade
fix(repl): scope interactive max-turns to local query loops
jatmn Aug 1, 2026
40c2905
feat(config): add interactive max turns under /config
jatmn Aug 1, 2026
ef787da
docs(repl): clarify invalid OPENCLAUDE_MAX_TURNS precedence
jatmn Aug 1, 2026
6f124b8
fix(repl): address PR review on max-turns help and web version gate
jatmn Aug 1, 2026
05ecfba
fix(repl): typecheck Commander maxTurns opts and warn on invalid env
jatmn Aug 1, 2026
d7594da
fix(repl): warn when max turns is unlimited
jatmn Aug 1, 2026
2908082
fix(repl): scope unlimited-turn warning locally
jatmn Aug 1, 2026
f7135e7
fix(repl): preserve interactive turn caps across backgrounding
jatmn Aug 1, 2026
012eda9
fix(repl): preserve turn caps when backgrounding
jatmn Aug 2, 2026
8eca6ad
fix(repl): share turn budget across background handoff
jatmn Aug 2, 2026
3c7db93
fix(repl): reserve turns at provider dispatch
jatmn Aug 2, 2026
afbe89f
fix(repl): snapshot background handoff transcript
jatmn Aug 2, 2026
a789f19
fix(tasks): avoid phantom background session task
jatmn Aug 2, 2026
3a43020
fix(repl): preserve handoff lifecycle state
jatmn Aug 2, 2026
09e794f
fix(repl): own pending background handoffs
jatmn Aug 2, 2026
14b1c8b
test(tasks): isolate background session task storage
jatmn Aug 2, 2026
ffe9c02
fix(repl): refresh background task title and test cleanup
jatmn Aug 2, 2026
b1f5930
test(repl): cover max-turn CLI dispatch paths
jatmn Aug 2, 2026
94d714b
test(queue): cover prepend notification and priority
jatmn Aug 2, 2026
4fa9c8d
fix(repl): skip background handoff after foreground query throws
jatmn Aug 6, 2026
2978cb1
fix(repl): address PR review findings on notifications and handoff
jatmn Aug 6, 2026
f746034
fix(repl): resolve latest CodeRabbit inline review findings
jatmn Aug 6, 2026
fadcdc6
fix(repl): keep notification restore active until provider dispatch
jatmn Aug 6, 2026
465bf47
test(repl): cover post-dispatch ownership and headless max-turns 0
jatmn Aug 6, 2026
4191f3d
fix(repl): restore only embeddable notifications on Ctrl+B handoff abort
jatmn Aug 6, 2026
2db82eb
fix(repl): address review findings on background handoff
jatmn Aug 6, 2026
3a40063
fix(repl): guard deferred cap restore and skip remote turn limits
jatmn Aug 6, 2026
926b2e7
fix(repl): use messagesRef for deferred cap transcript anchor
jatmn Aug 6, 2026
351b604
test: harden context fallback warning assertion after max-turns tests
jatmn Aug 6, 2026
b62b1b5
test: address PR review findings on headless max-turns boundary
jatmn Aug 6, 2026
bb9a1b1
test: tidy headless max-turns boundary test and env isolation
jatmn Aug 6, 2026
b1ac0c8
fix: address PR review findings for max-turns background handoff
jatmn Aug 6, 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
1 change: 1 addition & 0 deletions docs/advanced-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,7 @@ host. Without this variable the behavior is unchanged.
| `CODEX_AUTH_JSON_PATH` | Codex only | Path to a Codex CLI `auth.json` file |
| `CODEX_HOME` | Codex only | Alternative Codex home directory |
| `OPENCLAUDE_MAX_RETRIES` | No | Maximum retry attempts for retryable API failures, capped at 100 (default: 10). Set to `0` to disable retries after the initial request. If unset, deprecated `CLAUDE_CODE_MAX_RETRIES` is still honored for compatibility. |
| `OPENCLAUDE_MAX_TURNS` | No | Per-prompt **local** interactive REPL turn cap for the in-process query loop. Defaults to `50`. Set a larger positive integer for long autonomous local interactive sessions (for example models that take many small tool steps). CLI `--max-turns 0` explicitly disables this cap and prints a cautionary warning. Precedence for a valid override: CLI `--max-turns` → this env var → legacy `CLAUDE_CODE_MAX_TURNS` (only when this var is unset/empty) → `/config` → Max turns (interactive) → `50`. If this env var is set but invalid (zero, negative, non-integer), the default `50` is used and lower layers are not consulted — same pattern as `OPENCLAUDE_MAX_RETRIES`. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`). |
| `OPENCLAUDE_RETRY_DELAY_MS` | No | Base retry delay in milliseconds for APIs that do not send `Retry-After`; exponential backoff starts from this value, capped at 60000 (default: 500) |
| `OPENCLAUDE_QUERY_HARD_MAX_MS` | No | Foreground query hard maximum in milliseconds. Defaults to 1800000 (30 minutes). Use a larger positive integer for long autonomous sessions; invalid, zero, negative, fractional, or timer-overflow values are ignored with a warning. |
| `OPENCLAUDE_DISABLE_CO_AUTHORED_BY` | No | Suppress the default `Co-Authored-By` trailer in generated git commits |
Expand Down
223 changes: 223 additions & 0 deletions src/cli/printMaxTurns.test.ts
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
})
})
2 changes: 2 additions & 0 deletions src/components/SessionBackgroundHint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ export function SessionBackgroundHint(t0) {
saveGlobalConfig(_temp2);
}
} else {
// Session backgrounding remains intentionally disabled until the
// handoff path is ready to ship; shell/agent backgrounding still runs.
if (isEnvTruthy("false") && isLoading) {
handleDoublePress();
}
Expand Down
27 changes: 27 additions & 0 deletions src/components/Settings/Config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type Outp
import { normalizeApiKeyForConfig } from '../../utils/authPortable.js';
import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup } from '../../utils/config.js';
import { normalizeCompactTailTurns } from '../../utils/relevancePruning.js';
import { normalizeReplMaxTurns, REPL_MAX_TURNS_OPTIONS } from '../../utils/replMaxTurns.js';
import chalk from 'chalk';
import { getModeColor, permissionModeTitle, permissionModeFromString, toExternalPermissionMode, isExternalPermissionMode, PERMISSION_MODES, type ExternalPermissionMode, type PermissionMode } from '../../utils/permissions/PermissionMode.js';
import { getAutoModeEnabledState, hasAutoModeOptInAnySource, transitionPlanAutoMode } from '../../utils/permissions/permissionSetup.js';
Expand Down Expand Up @@ -335,6 +336,29 @@ export function Config({
value: compactTailTurnsValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}
}, {
id: 'replMaxTurns',
label: 'Max turns (interactive)',
// Display/persist the saved preference (normalized). Effective runtime cap
// may still be overridden by CLI `--max-turns` or OPENCLAUDE_MAX_TURNS.
value: String(normalizeReplMaxTurns(globalConfig.replMaxTurns)),
// Include a hand-edited config value so it round-trips through the picker.
options: [...new Set([...REPL_MAX_TURNS_OPTIONS.map(String), String(normalizeReplMaxTurns(globalConfig.replMaxTurns))])],
type: 'enum' as const,
onChange(replMaxTurnsValue: string) {
const replMaxTurns = normalizeReplMaxTurns(replMaxTurnsValue);
saveGlobalConfig(current => ({
...current,
replMaxTurns
}));
setGlobalConfig({
...getGlobalConfig(),
replMaxTurns
});
logEvent('tengu_repl_max_turns_changed', {
value: replMaxTurnsValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS
});
}
}, {
id: 'toolHistoryCompressionEnabled',
label: 'Tool history compression',
Expand Down Expand Up @@ -1272,6 +1296,9 @@ export function Config({
if (globalConfig.compactTailTurns !== initialConfig.current.compactTailTurns) {
formattedChanges.push(`Set compaction recent messages kept to ${normalizeCompactTailTurns(globalConfig.compactTailTurns)}`);
}
if (globalConfig.replMaxTurns !== initialConfig.current.replMaxTurns) {
formattedChanges.push(`Set interactive max turns to ${normalizeReplMaxTurns(globalConfig.replMaxTurns)}`);
}
if (globalConfig.toolHistoryCompressionEnabled !== initialConfig.current.toolHistoryCompressionEnabled) {
formattedChanges.push(`${globalConfig.toolHistoryCompressionEnabled ? 'Enabled' : 'Disabled'} tool history compression`);
}
Expand Down
Loading