From 8c6c9f4f857f6ae53c583e89771ecc989038b6e5 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:26:53 -0700 Subject: [PATCH 01/34] fix(repl): make interactive max-turns configurable Wire --max-turns into interactive sessionConfig and honor OPENCLAUDE_MAX_TURNS / CLAUDE_CODE_MAX_TURNS so long autonomous REPL sessions can raise the default 50-turn per-prompt cap (fixes #2079). --- docs/advanced-setup.md | 1 + src/main.tsx | 4 +- src/screens/REPL.tsx | 3 +- src/screens/replMaxTurns.ts | 37 +++++++++++- src/screens/replMaxTurnsProp.test.ts | 85 +++++++++++++++++++++++++++- 5 files changed, 126 insertions(+), 4 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index e447b7eef0..5f6b40a0da 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -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 interactive REPL turn cap. Defaults to `50`. Set a larger positive integer for long autonomous interactive sessions (for example models that take many small tool steps). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. CLI `--max-turns` overrides this value. | | `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 | diff --git a/src/main.tsx b/src/main.tsx index a7832be9b6..7933f4de95 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -945,7 +945,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In interactive mode this overrides the default 50-turn REPL cap (also configurable via OPENCLAUDE_MAX_TURNS). In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { throw new Error('--max-budget-usd must be a positive number greater than 0'); @@ -3049,6 +3049,8 @@ async function run(): Promise { systemPrompt, appendSystemPrompt, thinkingConfig, + // Interactive REPL default is 50 via resolveReplMaxTurns; CLI wins over env. + maxTurns: options.maxTurns, }; // Shared context for processResumedConversation calls diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index c299f7cb44..7b8620b3be 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -572,7 +572,8 @@ function logQueryLifecycle(event: string, context: QueryLifecycleContext, extras logForDebugging(formatQueryLifecycleLogMessage(event, context, extras)); } // Default per-prompt cap for every local interactive REPL entrypoint. Headless -// and SDK callers retain their explicit maxTurns contracts. +// and SDK callers retain their explicit maxTurns contracts. Interactive callers +// can raise the cap via --max-turns or OPENCLAUDE_MAX_TURNS / CLAUDE_CODE_MAX_TURNS. export type Props = { commands: Command[]; debug: boolean; diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index c60c8ab86a..f4f922b8fc 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,5 +1,40 @@ export const DEFAULT_REPL_MAX_TURNS = 50 +/** + * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS when unset. + * Invalid, zero, negative, non-integer, or unsafe values are ignored. + */ +function parsePositiveTurnEnv(name: string): number | undefined { + const raw = process.env[name] + if (!raw?.trim()) return undefined + const parsed = Number(raw.trim()) + if (Number.isSafeInteger(parsed) && parsed > 0) { + return parsed + } + return undefined +} + +/** + * Resolve the per-prompt interactive REPL turn cap. + * + * Precedence: explicit prop (CLI `--max-turns`) → OPENCLAUDE_MAX_TURNS → + * CLAUDE_CODE_MAX_TURNS → DEFAULT_REPL_MAX_TURNS (50). + * + * Invalid explicit values fall through so a bad CLI parse cannot disable the + * interactive safety cap (unlike headless, where omitted maxTurns means no cap). + */ export function resolveReplMaxTurns(maxTurns?: number): number { - return maxTurns ?? DEFAULT_REPL_MAX_TURNS + if ( + typeof maxTurns === 'number' && + Number.isSafeInteger(maxTurns) && + maxTurns > 0 + ) { + return maxTurns + } + + return ( + parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? + parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') ?? + DEFAULT_REPL_MAX_TURNS + ) } diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index c125ba721d..efa8dd20b5 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,14 +1,22 @@ -import { describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' import { DEFAULT_REPL_MAX_TURNS, resolveReplMaxTurns } from './replMaxTurns.js' const screenDir = import.meta.dirname +const ENV_KEYS = ['OPENCLAUDE_MAX_TURNS', 'CLAUDE_CODE_MAX_TURNS'] as const +const savedEnv: Partial> = + {} + function readScreen(name: string): string { return readFileSync(join(screenDir, name), 'utf8') } +function readSourceUp(name: string): string { + return readFileSync(join(screenDir, '..', name), 'utf8') +} + function objectBody(source: string, marker: RegExp): string { const match = source.match(marker) expect(match).not.toBeNull() @@ -24,16 +32,74 @@ function objectBody(source: string, marker: RegExp): string { throw new Error(`Unclosed object after ${marker}`) } +function clearTurnEnv(): void { + for (const key of ENV_KEYS) { + delete process.env[key] + } +} + +afterEach(() => { + for (const key of ENV_KEYS) { + const previous = savedEnv[key] + if (previous === undefined) { + delete process.env[key] + } else { + process.env[key] = previous + } + } +}) + +for (const key of ENV_KEYS) { + savedEnv[key] = process.env[key] +} + describe('interactive REPL max-turn cap', () => { test('supplies the local interactive default at runtime', () => { + clearTurnEnv() expect(DEFAULT_REPL_MAX_TURNS).toBe(50) expect(resolveReplMaxTurns()).toBe(50) }) test('preserves an explicit interactive cap at runtime', () => { + clearTurnEnv() expect(resolveReplMaxTurns(7)).toBe(7) }) + test('honors OPENCLAUDE_MAX_TURNS when no explicit cap is passed', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = '200' + expect(resolveReplMaxTurns()).toBe(200) + }) + + test('falls back to CLAUDE_CODE_MAX_TURNS when OPENCLAUDE_MAX_TURNS is unset', () => { + clearTurnEnv() + process.env.CLAUDE_CODE_MAX_TURNS = '125' + expect(resolveReplMaxTurns()).toBe(125) + }) + + test('prefers OPENCLAUDE_MAX_TURNS over CLAUDE_CODE_MAX_TURNS', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = '90' + process.env.CLAUDE_CODE_MAX_TURNS = '30' + expect(resolveReplMaxTurns()).toBe(90) + }) + + test('explicit CLI cap wins over environment overrides', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = '200' + expect(resolveReplMaxTurns(80)).toBe(80) + }) + + test('ignores invalid env and explicit values and keeps the default', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = 'nope' + expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) + expect(resolveReplMaxTurns(0)).toBe(DEFAULT_REPL_MAX_TURNS) + expect(resolveReplMaxTurns(-3)).toBe(DEFAULT_REPL_MAX_TURNS) + expect(resolveReplMaxTurns(Number.NaN)).toBe(DEFAULT_REPL_MAX_TURNS) + expect(resolveReplMaxTurns(2.5)).toBe(DEFAULT_REPL_MAX_TURNS) + }) + test('passes the resolved cap to foreground and background queries', () => { const source = readScreen('REPL.tsx') const foreground = objectBody(source, /for await \(const event of query\(\{/) @@ -49,4 +115,21 @@ describe('interactive REPL max-turn cap', () => { expect(repl).toContain('maxTurns={maxTurns}') }) + + test('sessionConfig wires maxTurns from the CLI flag', () => { + const source = readSourceUp('main.tsx') + const body = objectBody(source, /const sessionConfig = \{/) + expect(body).toContain('maxTurns: options.maxTurns') + }) + + test('--max-turns help no longer claims print-only', () => { + const source = readSourceUp('main.tsx') + const optionMatch = source.match( + /\.addOption\(\s*new Option\(\s*'--max-turns ',\s*'([^']+)'/, + ) + expect(optionMatch).not.toBeNull() + const help = optionMatch![1] + expect(help.toLowerCase()).not.toContain('only works with --print') + expect(help.toLowerCase()).toContain('interactive') + }) }) From 3c949f626af09d8c3800da47f84ad1961b2b13c0 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:28:45 -0700 Subject: [PATCH 02/34] fix(repl): forward --max-turns on connect/ssh/remote launches sessionConfig covered the normal interactive paths; connect, SSH, assistant, and --remote built REPL props without spreading it, so the CLI override was dropped despite help advertising interactive support. --- src/main.tsx | 12 ++++++++---- src/screens/replMaxTurnsProp.test.ts | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 7933f4de95..cee204c6b7 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3151,7 +3151,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, directConnectConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { @@ -3217,7 +3218,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, sshSession, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (feature('KAIROS') && _pendingAssistantChat && (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover)) { @@ -3313,7 +3315,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (options.resume || options.fromPr || teleport || remote !== null) { @@ -3462,7 +3465,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (teleport) { diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index efa8dd20b5..bf126aafd3 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -122,6 +122,23 @@ describe('interactive REPL max-turn cap', () => { expect(body).toContain('maxTurns: options.maxTurns') }) + test('non-sessionConfig interactive launch paths forward maxTurns', () => { + // connect / ssh / assistant / --remote build REPL props without + // spreading sessionConfig; each must still pass the CLI override. + const source = readSourceUp('main.tsx') + const markers = [ + /directConnectConfig,\s*\n\s*thinkingConfig,\s*\n\s*maxTurns: options\.maxTurns/, + /sshSession,\s*\n\s*thinkingConfig,\s*\n\s*maxTurns: options\.maxTurns/, + ] + for (const marker of markers) { + expect(source).toMatch(marker) + } + // Count explicit maxTurns: options.maxTurns assignments outside + // sessionConfig — connect, ssh, assistant, remote (+ sessionConfig = 5). + const matches = source.match(/maxTurns:\s*options\.maxTurns/g) ?? [] + expect(matches.length).toBeGreaterThanOrEqual(5) + }) + test('--max-turns help no longer claims print-only', () => { const source = readSourceUp('main.tsx') const optionMatch = source.match( From 607dade7a36310447a51c6ee86534de2dbfa673d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:30:21 -0700 Subject: [PATCH 03/34] fix(repl): scope interactive max-turns to local query loops Remote-backed sessions bypass local query(), so forwarding --max-turns into those REPL props over-claimed enforcement. Clarify help/docs and match OPENCLAUDE_MAX_RETRIES precedence when OPENCLAUDE_MAX_TURNS is set but invalid. --- docs/advanced-setup.md | 2 +- src/main.tsx | 14 +++++-------- src/screens/REPL.tsx | 5 +++-- src/screens/replMaxTurns.ts | 27 ++++++++++++++++-------- src/screens/replMaxTurnsProp.test.ts | 31 ++++++++++------------------ 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 5f6b40a0da..390cf8896d 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -478,7 +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 interactive REPL turn cap. Defaults to `50`. Set a larger positive integer for long autonomous interactive sessions (for example models that take many small tool steps). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. CLI `--max-turns` overrides this value. | +| `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). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. CLI `--max-turns` overrides this value. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`), which execute on the remote side. | | `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 | diff --git a/src/main.tsx b/src/main.tsx index cee204c6b7..016b6e0afb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -945,7 +945,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In interactive mode this overrides the default 50-turn REPL cap (also configurable via OPENCLAUDE_MAX_TURNS). In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { throw new Error('--max-budget-usd must be a positive number greater than 0'); @@ -3151,8 +3151,7 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, directConnectConfig, - thinkingConfig, - maxTurns: options.maxTurns, + thinkingConfig }, renderAndRun); return; } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { @@ -3218,8 +3217,7 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, sshSession, - thinkingConfig, - maxTurns: options.maxTurns, + thinkingConfig }, renderAndRun); return; } else if (feature('KAIROS') && _pendingAssistantChat && (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover)) { @@ -3315,8 +3313,7 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig, - maxTurns: options.maxTurns, + thinkingConfig }, renderAndRun); return; } else if (options.resume || options.fromPr || teleport || remote !== null) { @@ -3465,8 +3462,7 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig, - maxTurns: options.maxTurns, + thinkingConfig }, renderAndRun); return; } else if (teleport) { diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 7b8620b3be..7224059a1f 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -572,8 +572,9 @@ function logQueryLifecycle(event: string, context: QueryLifecycleContext, extras logForDebugging(formatQueryLifecycleLogMessage(event, context, extras)); } // Default per-prompt cap for every local interactive REPL entrypoint. Headless -// and SDK callers retain their explicit maxTurns contracts. Interactive callers -// can raise the cap via --max-turns or OPENCLAUDE_MAX_TURNS / CLAUDE_CODE_MAX_TURNS. +// and SDK callers retain their explicit maxTurns contracts. Local interactive +// callers can raise the cap via --max-turns or OPENCLAUDE_MAX_TURNS / +// CLAUDE_CODE_MAX_TURNS. Remote-backed sessions are not capped here. export type Props = { commands: Command[]; debug: boolean; diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index f4f922b8fc..67ca138e0e 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,8 +1,9 @@ export const DEFAULT_REPL_MAX_TURNS = 50 /** - * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS when unset. - * Invalid, zero, negative, non-integer, or unsafe values are ignored. + * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when + * the new variable is unset/empty. Invalid, zero, negative, non-integer, or + * unsafe values are ignored (treated as absent for the chosen variable). */ function parsePositiveTurnEnv(name: string): number | undefined { const raw = process.env[name] @@ -15,13 +16,20 @@ function parsePositiveTurnEnv(name: string): number | undefined { } /** - * Resolve the per-prompt interactive REPL turn cap. + * Resolve the per-prompt local interactive REPL turn cap. * * Precedence: explicit prop (CLI `--max-turns`) → OPENCLAUDE_MAX_TURNS → - * CLAUDE_CODE_MAX_TURNS → DEFAULT_REPL_MAX_TURNS (50). + * CLAUDE_CODE_MAX_TURNS (only if OPENCLAUDE_MAX_TURNS unset) → + * DEFAULT_REPL_MAX_TURNS (50). + * + * Applies to local interactive query loops only. Remote-backed sessions + * (connect/ssh/--remote) send prompts to a remote executor and are not + * capped here. * * Invalid explicit values fall through so a bad CLI parse cannot disable the * interactive safety cap (unlike headless, where omitted maxTurns means no cap). + * If OPENCLAUDE_MAX_TURNS is set but invalid, the default is used (legacy is + * not consulted), matching OPENCLAUDE_MAX_RETRIES precedence. */ export function resolveReplMaxTurns(maxTurns?: number): number { if ( @@ -32,9 +40,10 @@ export function resolveReplMaxTurns(maxTurns?: number): number { return maxTurns } - return ( - parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? - parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') ?? - DEFAULT_REPL_MAX_TURNS - ) + const openClaudeRaw = process.env.OPENCLAUDE_MAX_TURNS + if (openClaudeRaw?.trim()) { + return parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS + } + + return parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS } diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index bf126aafd3..a79c22aa08 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -84,6 +84,13 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns()).toBe(90) }) + test('invalid OPENCLAUDE_MAX_TURNS does not fall through to legacy', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = 'nope' + process.env.CLAUDE_CODE_MAX_TURNS = '125' + expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) + }) + test('explicit CLI cap wins over environment overrides', () => { clearTurnEnv() process.env.OPENCLAUDE_MAX_TURNS = '200' @@ -116,30 +123,13 @@ describe('interactive REPL max-turn cap', () => { expect(repl).toContain('maxTurns={maxTurns}') }) - test('sessionConfig wires maxTurns from the CLI flag', () => { + test('sessionConfig wires maxTurns from the CLI flag for local interactive paths', () => { const source = readSourceUp('main.tsx') const body = objectBody(source, /const sessionConfig = \{/) expect(body).toContain('maxTurns: options.maxTurns') }) - test('non-sessionConfig interactive launch paths forward maxTurns', () => { - // connect / ssh / assistant / --remote build REPL props without - // spreading sessionConfig; each must still pass the CLI override. - const source = readSourceUp('main.tsx') - const markers = [ - /directConnectConfig,\s*\n\s*thinkingConfig,\s*\n\s*maxTurns: options\.maxTurns/, - /sshSession,\s*\n\s*thinkingConfig,\s*\n\s*maxTurns: options\.maxTurns/, - ] - for (const marker of markers) { - expect(source).toMatch(marker) - } - // Count explicit maxTurns: options.maxTurns assignments outside - // sessionConfig — connect, ssh, assistant, remote (+ sessionConfig = 5). - const matches = source.match(/maxTurns:\s*options\.maxTurns/g) ?? [] - expect(matches.length).toBeGreaterThanOrEqual(5) - }) - - test('--max-turns help no longer claims print-only', () => { + test('--max-turns help scopes the interactive cap to local query loops', () => { const source = readSourceUp('main.tsx') const optionMatch = source.match( /\.addOption\(\s*new Option\(\s*'--max-turns ',\s*'([^']+)'/, @@ -147,6 +137,7 @@ describe('interactive REPL max-turn cap', () => { expect(optionMatch).not.toBeNull() const help = optionMatch![1] expect(help.toLowerCase()).not.toContain('only works with --print') - expect(help.toLowerCase()).toContain('interactive') + expect(help.toLowerCase()).toContain('local interactive') + expect(help.toLowerCase()).toContain('remote-backed') }) }) From 40c29051cdc7c32e56afc56b5147609b1c6e3eaf Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:35:28 -0700 Subject: [PATCH 04/34] feat(config): add interactive max turns under /config Expose replMaxTurns in the Config panel (50/100/200/500) and resolve it after CLI/env so local interactive sessions can raise the per-prompt cap without restarting. Resolve at query time so mid-session /config changes apply on the next prompt. --- docs/advanced-setup.md | 2 +- src/components/Settings/Config.tsx | 27 +++++++++ src/main.tsx | 2 +- src/screens/REPL.tsx | 16 +++--- src/screens/replMaxTurns.ts | 55 ++---------------- src/screens/replMaxTurnsProp.test.ts | 66 +++++++++++++++++++++- src/utils/config.ts | 6 ++ src/utils/replMaxTurns.ts | 83 ++++++++++++++++++++++++++++ 8 files changed, 196 insertions(+), 61 deletions(-) create mode 100644 src/utils/replMaxTurns.ts diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 390cf8896d..bc6de2eb05 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -478,7 +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). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. CLI `--max-turns` overrides this value. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`), which execute on the remote side. | +| `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). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. Precedence: CLI `--max-turns` → this env var → legacy env → `/config` → Max turns (interactive) → `50`. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`), which execute on the remote side. | | `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 | diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 815c58e09a..a35c722e95 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -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'; @@ -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 and persist through the SAME normalization resolveReplMaxTurns + // applies, so a hand-edited 2.5 or 0 shows (and saves) as what actually runs. + 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', @@ -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`); } diff --git a/src/main.tsx b/src/main.tsx index 016b6e0afb..58bd74da96 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -945,7 +945,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { throw new Error('--max-budget-usd must be a positive number greater than 0'); diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 7224059a1f..17372204d6 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -573,8 +573,9 @@ function logQueryLifecycle(event: string, context: QueryLifecycleContext, extras } // Default per-prompt cap for every local interactive REPL entrypoint. Headless // and SDK callers retain their explicit maxTurns contracts. Local interactive -// callers can raise the cap via --max-turns or OPENCLAUDE_MAX_TURNS / -// CLAUDE_CODE_MAX_TURNS. Remote-backed sessions are not capped here. +// callers can raise the cap via --max-turns, OPENCLAUDE_MAX_TURNS / +// CLAUDE_CODE_MAX_TURNS, or `/config` → Max turns (interactive). +// Remote-backed sessions are not capped here. export type Props = { commands: Command[]; debug: boolean; @@ -657,7 +658,8 @@ export function REPL({ fallbackModel, maxTurns: maxTurnsProp }: Props): React.ReactNode { - const maxTurns = resolveReplMaxTurns(maxTurnsProp) + // Resolve at query time so `/config` changes apply on the next prompt + // without requiring a REPL remount. CLI prop still wins over env/config. const isRemoteSession = !!remoteSessionConfig; // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ @@ -2874,7 +2876,7 @@ export function REPL({ canUseTool, toolUseContext, fallbackModel, - maxTurns, + maxTurns: resolveReplMaxTurns(maxTurnsProp), querySource: getQuerySourceForREPL(), autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), onAutoCompactTrackingChange: tracking => { @@ -2886,7 +2888,7 @@ export function REPL({ agentDefinition: mainThreadAgentDefinition }); })(); - }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, maxTurns]); + }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, maxTurnsProp]); const { handleBackgroundSession } = useSessionBackgrounding({ @@ -3118,7 +3120,7 @@ export function REPL({ toolUseContext, querySource: getQuerySourceForREPL(), fallbackModel, - maxTurns, + maxTurns: resolveReplMaxTurns(maxTurnsProp), autoCompactTracking: queryAutoCompactTracking, onAutoCompactTrackingChange: tracking => { if (setAutoCompactTrackingForSessionIfUnchanged(querySessionId, expectedAutoCompactTracking, tracking)) { @@ -3144,7 +3146,7 @@ export function REPL({ // Signal that a query turn has completed successfully await onTurnComplete?.(messagesRef.current); - }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, maxTurns, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard, interruptionCorrectionTracker]); + }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, maxTurnsProp, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard, interruptionCorrectionTracker]); const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue, isInterruptionCorrectionEligible = false, onModelRequestStart?: () => void): Promise => { // If this is a teammate, mark them as active when starting a turn if (isAgentSwarmsEnabled()) { diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 67ca138e0e..023f024dc2 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,49 +1,6 @@ -export const DEFAULT_REPL_MAX_TURNS = 50 - -/** - * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when - * the new variable is unset/empty. Invalid, zero, negative, non-integer, or - * unsafe values are ignored (treated as absent for the chosen variable). - */ -function parsePositiveTurnEnv(name: string): number | undefined { - const raw = process.env[name] - if (!raw?.trim()) return undefined - const parsed = Number(raw.trim()) - if (Number.isSafeInteger(parsed) && parsed > 0) { - return parsed - } - return undefined -} - -/** - * Resolve the per-prompt local interactive REPL turn cap. - * - * Precedence: explicit prop (CLI `--max-turns`) → OPENCLAUDE_MAX_TURNS → - * CLAUDE_CODE_MAX_TURNS (only if OPENCLAUDE_MAX_TURNS unset) → - * DEFAULT_REPL_MAX_TURNS (50). - * - * Applies to local interactive query loops only. Remote-backed sessions - * (connect/ssh/--remote) send prompts to a remote executor and are not - * capped here. - * - * Invalid explicit values fall through so a bad CLI parse cannot disable the - * interactive safety cap (unlike headless, where omitted maxTurns means no cap). - * If OPENCLAUDE_MAX_TURNS is set but invalid, the default is used (legacy is - * not consulted), matching OPENCLAUDE_MAX_RETRIES precedence. - */ -export function resolveReplMaxTurns(maxTurns?: number): number { - if ( - typeof maxTurns === 'number' && - Number.isSafeInteger(maxTurns) && - maxTurns > 0 - ) { - return maxTurns - } - - const openClaudeRaw = process.env.OPENCLAUDE_MAX_TURNS - if (openClaudeRaw?.trim()) { - return parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS - } - - return parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS -} +export { + DEFAULT_REPL_MAX_TURNS, + REPL_MAX_TURNS_OPTIONS, + normalizeReplMaxTurns, + resolveReplMaxTurns, +} from '../utils/replMaxTurns.js' diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index a79c22aa08..f68d11295b 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,7 +1,18 @@ import { afterEach, describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { DEFAULT_REPL_MAX_TURNS, resolveReplMaxTurns } from './replMaxTurns.js' +import { + DEFAULT_GLOBAL_CONFIG, + GLOBAL_CONFIG_KEYS, + isGlobalConfigKey, + saveGlobalConfig, +} from '../utils/config.js' +import { + DEFAULT_REPL_MAX_TURNS, + normalizeReplMaxTurns, + REPL_MAX_TURNS_OPTIONS, + resolveReplMaxTurns, +} from '../utils/replMaxTurns.js' const screenDir = import.meta.dirname @@ -38,6 +49,13 @@ function clearTurnEnv(): void { } } +function setReplMaxTurnsConfig(value: number | undefined): void { + saveGlobalConfig(current => ({ + ...current, + replMaxTurns: value, + })) +} + afterEach(() => { for (const key of ENV_KEYS) { const previous = savedEnv[key] @@ -47,6 +65,7 @@ afterEach(() => { process.env[key] = previous } } + setReplMaxTurnsConfig(undefined) }) for (const key of ENV_KEYS) { @@ -56,6 +75,7 @@ for (const key of ENV_KEYS) { describe('interactive REPL max-turn cap', () => { test('supplies the local interactive default at runtime', () => { clearTurnEnv() + setReplMaxTurnsConfig(undefined) expect(DEFAULT_REPL_MAX_TURNS).toBe(50) expect(resolveReplMaxTurns()).toBe(50) }) @@ -97,8 +117,28 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns(80)).toBe(80) }) + test('honors /config replMaxTurns when CLI and env are unset', () => { + clearTurnEnv() + setReplMaxTurnsConfig(200) + expect(resolveReplMaxTurns()).toBe(200) + }) + + test('env wins over /config replMaxTurns', () => { + clearTurnEnv() + setReplMaxTurnsConfig(200) + process.env.OPENCLAUDE_MAX_TURNS = '80' + expect(resolveReplMaxTurns()).toBe(80) + }) + + test('CLI wins over /config replMaxTurns', () => { + clearTurnEnv() + setReplMaxTurnsConfig(200) + expect(resolveReplMaxTurns(90)).toBe(90) + }) + test('ignores invalid env and explicit values and keeps the default', () => { clearTurnEnv() + setReplMaxTurnsConfig(undefined) process.env.OPENCLAUDE_MAX_TURNS = 'nope' expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) expect(resolveReplMaxTurns(0)).toBe(DEFAULT_REPL_MAX_TURNS) @@ -107,13 +147,33 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns(2.5)).toBe(DEFAULT_REPL_MAX_TURNS) }) + test('normalizeReplMaxTurns matches /config picker persistence', () => { + expect(normalizeReplMaxTurns(200)).toBe(200) + expect(normalizeReplMaxTurns('500')).toBe(500) + expect(normalizeReplMaxTurns(0)).toBe(DEFAULT_REPL_MAX_TURNS) + expect(normalizeReplMaxTurns('nope')).toBe(DEFAULT_REPL_MAX_TURNS) + }) + + test('replMaxTurns is registered for /config', () => { + expect(GLOBAL_CONFIG_KEYS).toContain('replMaxTurns') + expect(isGlobalConfigKey('replMaxTurns')).toBe(true) + expect(DEFAULT_GLOBAL_CONFIG.replMaxTurns).toBeUndefined() + expect(REPL_MAX_TURNS_OPTIONS).toEqual([50, 100, 200, 500]) + }) + test('passes the resolved cap to foreground and background queries', () => { const source = readScreen('REPL.tsx') const foreground = objectBody(source, /for await \(const event of query\(\{/) const background = objectBody(source, /queryParams:\s*\{/) - expect(foreground).toContain('maxTurns,') - expect(background).toContain('maxTurns,') + expect(foreground).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') + expect(background).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') + }) + + test('Config panel exposes Max turns (interactive)', () => { + const source = readSourceUp(join('components', 'Settings', 'Config.tsx')) + expect(source).toContain("id: 'replMaxTurns'") + expect(source).toContain("label: 'Max turns (interactive)'") }) test('passes the cap from the resume selector into REPL', () => { diff --git a/src/utils/config.ts b/src/utils/config.ts index e922754771..3468d4adda 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -294,6 +294,11 @@ export type GlobalConfig = { contextCollapseEnabled: boolean // Opt-in: collapse old transcript spans into summaries (lossy; off by default) toolHistoryCompressionEnabled: boolean // Compress old tool_result content (shim providers; Anthropic-native only while prompt caching is inactive) compactTailTurns?: number // Recent messages preserved verbatim by auto-compact's relevance pruning (default: 3) + /** + * Per-prompt local interactive REPL turn cap (default: 50). + * Overridden by CLI `--max-turns` and OPENCLAUDE_MAX_TURNS / CLAUDE_CODE_MAX_TURNS. + */ + replMaxTurns?: number showTurnDuration: boolean // Controls whether to show turn duration message (e.g., "Cooked for 1m 6s") // Controls whether to show per-query cache hit/miss stats at the end of each turn. // 'off' — no display @@ -771,6 +776,7 @@ export const GLOBAL_CONFIG_KEYS = [ 'hasUsedBackslashReturn', 'autoCompactEnabled', 'compactTailTurns', + 'replMaxTurns', 'contextCollapseEnabled', 'toolHistoryCompressionEnabled', 'showTurnDuration', diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts new file mode 100644 index 0000000000..291a4acd13 --- /dev/null +++ b/src/utils/replMaxTurns.ts @@ -0,0 +1,83 @@ +import { getGlobalConfig } from '../utils/config.js' + +export const DEFAULT_REPL_MAX_TURNS = 50 + +/** Preset values offered in `/config` (plus any current custom value). */ +export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const + +/** + * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when + * the new variable is unset/empty. Invalid, zero, negative, non-integer, or + * unsafe values are ignored (treated as absent for the chosen variable). + */ +function parsePositiveTurnEnv(name: string): number | undefined { + const raw = process.env[name] + if (!raw?.trim()) return undefined + const parsed = Number(raw.trim()) + if (Number.isSafeInteger(parsed) && parsed > 0) { + return parsed + } + return undefined +} + +/** + * Normalize a persisted or UI-selected interactive turn cap. + * Invalid values fall back to DEFAULT_REPL_MAX_TURNS. + */ +export function normalizeReplMaxTurns(value: unknown): number { + if (typeof value === 'number') { + if (Number.isSafeInteger(value) && value > 0) return value + return DEFAULT_REPL_MAX_TURNS + } + if (typeof value === 'string' && value.trim()) { + const parsed = Number(value.trim()) + if (Number.isSafeInteger(parsed) && parsed > 0) return parsed + } + return DEFAULT_REPL_MAX_TURNS +} + +function resolveConfiguredReplMaxTurns(): number { + const configured = getGlobalConfig().replMaxTurns + if (configured === undefined) { + return DEFAULT_REPL_MAX_TURNS + } + return normalizeReplMaxTurns(configured) +} + +/** + * Resolve the per-prompt local interactive REPL turn cap. + * + * Precedence: explicit prop (CLI `--max-turns`) → OPENCLAUDE_MAX_TURNS → + * CLAUDE_CODE_MAX_TURNS (only if OPENCLAUDE_MAX_TURNS unset) → + * `/config` `replMaxTurns` → DEFAULT_REPL_MAX_TURNS (50). + * + * Applies to local interactive query loops only. Remote-backed sessions + * (connect/ssh/--remote) send prompts to a remote executor and are not + * capped here. + * + * Invalid explicit values fall through so a bad CLI parse cannot disable the + * interactive safety cap (unlike headless, where omitted maxTurns means no cap). + * If OPENCLAUDE_MAX_TURNS is set but invalid, the default is used (legacy is + * not consulted), matching OPENCLAUDE_MAX_RETRIES precedence. + */ +export function resolveReplMaxTurns(maxTurns?: number): number { + if ( + typeof maxTurns === 'number' && + Number.isSafeInteger(maxTurns) && + maxTurns > 0 + ) { + return maxTurns + } + + const openClaudeRaw = process.env.OPENCLAUDE_MAX_TURNS + if (openClaudeRaw?.trim()) { + return parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS + } + + const legacy = parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') + if (legacy !== undefined) { + return legacy + } + + return resolveConfiguredReplMaxTurns() +} From ef787dac5a158625fc7b3d4a2fd50f3f45461a2f Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:37:13 -0700 Subject: [PATCH 05/34] docs(repl): clarify invalid OPENCLAUDE_MAX_TURNS precedence Match the OPENCLAUDE_MAX_RETRIES contract: a set-but-invalid primary env var uses the default and does not fall through to legacy or /config. --- docs/advanced-setup.md | 2 +- src/utils/replMaxTurns.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index bc6de2eb05..9e590ab50f 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -478,7 +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). Invalid, zero, negative, or non-integer values are ignored. If unset, deprecated `CLAUDE_CODE_MAX_TURNS` is still honored. Precedence: CLI `--max-turns` → this env var → legacy env → `/config` → Max turns (interactive) → `50`. Does not apply to remote-backed interactive sessions (`connect` / `ssh` / `--remote`), which execute on the remote side. | +| `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). 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`), which execute on the remote side. | | `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 | diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index 291a4acd13..1995204bb2 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -57,8 +57,9 @@ function resolveConfiguredReplMaxTurns(): number { * * Invalid explicit values fall through so a bad CLI parse cannot disable the * interactive safety cap (unlike headless, where omitted maxTurns means no cap). - * If OPENCLAUDE_MAX_TURNS is set but invalid, the default is used (legacy is - * not consulted), matching OPENCLAUDE_MAX_RETRIES precedence. + * If OPENCLAUDE_MAX_TURNS is set but invalid, DEFAULT_REPL_MAX_TURNS is + * used and lower layers (legacy env, /config) are not consulted — matching + * OPENCLAUDE_MAX_RETRIES precedence. */ export function resolveReplMaxTurns(maxTurns?: number): number { if ( From 6f124b8f056ae266da008e91b48fc3235bf9514a Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:44:03 -0700 Subject: [PATCH 06/34] fix(repl): address PR review on max-turns help and web version gate Share the --max-turns Commander description via an imported constant so help and tests stay in sync without breaking the CLI bundle, replace source-only help assertions with Commander behavior coverage, and add the published 0.27.0 entry so web verify-dist passes. --- src/main.tsx | 3 +- src/screens/replMaxTurns.ts | 1 + src/screens/replMaxTurnsProp.test.ts | 67 ++++++++++++++++++++++------ src/utils/replMaxTurns.ts | 7 +++ 4 files changed, 64 insertions(+), 14 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 58bd74da96..db09b8f928 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -123,6 +123,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; +import { MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -945,7 +946,7 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.').argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { throw new Error('--max-budget-usd must be a positive number greater than 0'); diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 023f024dc2..e5a2cd4e02 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,5 +1,6 @@ export { DEFAULT_REPL_MAX_TURNS, + MAX_TURNS_CLI_DESCRIPTION, REPL_MAX_TURNS_OPTIONS, normalizeReplMaxTurns, resolveReplMaxTurns, diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index f68d11295b..7807f86056 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' +import { Command as CommanderCommand, Option } from '@commander-js/extra-typings' import { DEFAULT_GLOBAL_CONFIG, GLOBAL_CONFIG_KEYS, @@ -9,6 +10,7 @@ import { } from '../utils/config.js' import { DEFAULT_REPL_MAX_TURNS, + MAX_TURNS_CLI_DESCRIPTION, normalizeReplMaxTurns, REPL_MAX_TURNS_OPTIONS, resolveReplMaxTurns, @@ -56,6 +58,19 @@ function setReplMaxTurnsConfig(value: number | undefined): void { })) } +function createMaxTurnsCliProgram(): CommanderCommand { + const program = new CommanderCommand() + program + .name('openclaude') + .addOption( + new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser( + Number, + ), + ) + .action(() => {}) + return program +} + afterEach(() => { for (const key of ENV_KEYS) { const previous = savedEnv[key] @@ -178,26 +193,52 @@ describe('interactive REPL max-turn cap', () => { test('passes the cap from the resume selector into REPL', () => { const source = readScreen('ResumeConversation.tsx') - const repl = source.slice(source.indexOf('', source.indexOf('', source.indexOf(' { - const source = readSourceUp('main.tsx') - const body = objectBody(source, /const sessionConfig = \{/) - expect(body).toContain('maxTurns: options.maxTurns') + test('Commander --max-turns help scopes the interactive cap to local query loops', () => { + // Commander wraps long option help across lines; collapse whitespace. + const help = createMaxTurnsCliProgram() + .helpInformation() + .toLowerCase() + .replace(/\s+/g, ' ') + expect(help).toContain('--max-turns') + expect(help).toContain('local interactive') + expect(help).toContain('remote-backed') + expect(help).not.toContain('only works with --print') }) - test('--max-turns help scopes the interactive cap to local query loops', () => { + test('Commander --max-turns parses into the value the local REPL resolves', async () => { + clearTurnEnv() + setReplMaxTurnsConfig(50) + const program = createMaxTurnsCliProgram() + await program.parseAsync(['node', 'openclaude', '--max-turns', '200'], { + from: 'node', + }) + const parsed = program.opts().maxTurns + expect(parsed).toBe(200) + // Same handoff the interactive session uses: CLI option → resolveReplMaxTurns. + expect(resolveReplMaxTurns(parsed)).toBe(200) + }) + + test('main imports the shared --max-turns description and wires sessionConfig', () => { + // main.tsx is hard to boot in unit tests; assert the import so the + // shared constant cannot be referenced without being bundled, plus the + // local interactive sessionConfig handoff (same pattern as fallbackModel). const source = readSourceUp('main.tsx') - const optionMatch = source.match( - /\.addOption\(\s*new Option\(\s*'--max-turns ',\s*'([^']+)'/, + expect(source).toMatch( + /import\s*\{[^}]*\bMAX_TURNS_CLI_DESCRIPTION\b[^}]*\}\s*from\s*'\.\/utils\/replMaxTurns\.js'/, + ) + expect(source).toContain( + "new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION)", ) - expect(optionMatch).not.toBeNull() - const help = optionMatch![1] - expect(help.toLowerCase()).not.toContain('only works with --print') - expect(help.toLowerCase()).toContain('local interactive') - expect(help.toLowerCase()).toContain('remote-backed') + const body = objectBody(source, /const sessionConfig = \{/) + expect(body).toContain('maxTurns: options.maxTurns') }) }) + diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index 1995204bb2..03e84873e9 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -5,6 +5,13 @@ export const DEFAULT_REPL_MAX_TURNS = 50 /** Preset values offered in `/config` (plus any current custom value). */ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const +/** + * Shared `--max-turns` help text for Commander registration and behavior tests. + * Keep remote-backed sessions explicitly out of scope in this string. + */ +export const MAX_TURNS_CLI_DESCRIPTION = + 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' + /** * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when * the new variable is unset/empty. Invalid, zero, negative, non-integer, or From 05ecfba68074a735f4bbf97beac2a6f66ec2b72d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 14:49:56 -0700 Subject: [PATCH 07/34] fix(repl): typecheck Commander maxTurns opts and warn on invalid env Avoid TS2339 on untyped Commander opts, log invalid OPENCLAUDE_MAX_TURNS like MAX_RETRIES, and clarify that /config shows the persisted preference. --- src/components/Settings/Config.tsx | 4 ++-- src/screens/replMaxTurnsProp.test.ts | 2 +- src/utils/replMaxTurns.ts | 14 ++++++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index a35c722e95..89d246efbe 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -339,8 +339,8 @@ export function Config({ }, { id: 'replMaxTurns', label: 'Max turns (interactive)', - // Display and persist through the SAME normalization resolveReplMaxTurns - // applies, so a hand-edited 2.5 or 0 shows (and saves) as what actually runs. + // 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))])], diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 7807f86056..5c7ccc42d3 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -220,7 +220,7 @@ describe('interactive REPL max-turn cap', () => { await program.parseAsync(['node', 'openclaude', '--max-turns', '200'], { from: 'node', }) - const parsed = program.opts().maxTurns + const parsed = program.getOptionValue('maxTurns') as number expect(parsed).toBe(200) // Same handoff the interactive session uses: CLI option → resolveReplMaxTurns. expect(resolveReplMaxTurns(parsed)).toBe(200) diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index 03e84873e9..da1a84873d 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -1,4 +1,5 @@ -import { getGlobalConfig } from '../utils/config.js' +import { getGlobalConfig } from './config.js' +import { logForDebugging } from './debug.js' export const DEFAULT_REPL_MAX_TURNS = 50 @@ -79,7 +80,16 @@ export function resolveReplMaxTurns(maxTurns?: number): number { const openClaudeRaw = process.env.OPENCLAUDE_MAX_TURNS if (openClaudeRaw?.trim()) { - return parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') ?? DEFAULT_REPL_MAX_TURNS + const parsed = parsePositiveTurnEnv('OPENCLAUDE_MAX_TURNS') + if (parsed !== undefined) { + return parsed + } + // Match OPENCLAUDE_MAX_RETRIES: set-but-invalid uses the default and does + // not fall through to legacy env or /config; surface a debug diagnostic. + logForDebugging( + `OPENCLAUDE_MAX_TURNS Invalid value "${openClaudeRaw}" (using default: ${DEFAULT_REPL_MAX_TURNS})`, + ) + return DEFAULT_REPL_MAX_TURNS } const legacy = parsePositiveTurnEnv('CLAUDE_CODE_MAX_TURNS') From d7594da77d4678b6b432dba12a8bf22df74ce224 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 15:47:01 -0700 Subject: [PATCH 08/34] fix(repl): warn when max turns is unlimited --- docs/advanced-setup.md | 2 +- src/main.tsx | 7 ++++++- src/screens/replMaxTurnsProp.test.ts | 14 +++++++++++--- src/utils/replMaxTurns.ts | 18 ++++++++++++++---- 4 files changed, 32 insertions(+), 9 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 9e590ab50f..fc4772b9f2 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -478,7 +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). 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`), which execute on the remote side. | +| `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`), which execute on the remote side. | | `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 | diff --git a/src/main.tsx b/src/main.tsx index db09b8f928..78df7bd9f0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -123,7 +123,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; +import { getReplMaxTurnsWarning, MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -986,6 +986,11 @@ async function run(): Promise { .option('--provider-env-file ', 'Load provider environment variables from a file before validation (repeatable; existing values win)', (val: string, prev: string[]) => [...prev, val], [] as string[]).action(async (prompt, options) => { profileCheckpoint('action_handler_start'); + const maxTurnsWarning = getReplMaxTurnsWarning(options.maxTurns); + if (maxTurnsWarning) { + process.stderr.write(chalk.yellow(`${maxTurnsWarning}\n`)); + } + // --bare = one-switch minimal mode. Sets SIMPLE so all the existing // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent // dir-walk). Must be set before setup() / any of the gated work runs. diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 5c7ccc42d3..bba2aad272 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -10,7 +10,9 @@ import { } from '../utils/config.js' import { DEFAULT_REPL_MAX_TURNS, + getReplMaxTurnsWarning, MAX_TURNS_CLI_DESCRIPTION, + MAX_TURNS_UNLIMITED_WARNING, normalizeReplMaxTurns, REPL_MAX_TURNS_OPTIONS, resolveReplMaxTurns, @@ -151,17 +153,24 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns(90)).toBe(90) }) - test('ignores invalid env and explicit values and keeps the default', () => { + test('treats an explicit CLI zero as unlimited and invalid values as the default', () => { clearTurnEnv() setReplMaxTurnsConfig(undefined) process.env.OPENCLAUDE_MAX_TURNS = 'nope' expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) - expect(resolveReplMaxTurns(0)).toBe(DEFAULT_REPL_MAX_TURNS) + clearTurnEnv() + expect(resolveReplMaxTurns(0)).toBeUndefined() expect(resolveReplMaxTurns(-3)).toBe(DEFAULT_REPL_MAX_TURNS) expect(resolveReplMaxTurns(Number.NaN)).toBe(DEFAULT_REPL_MAX_TURNS) expect(resolveReplMaxTurns(2.5)).toBe(DEFAULT_REPL_MAX_TURNS) }) + test('warns when the CLI explicitly disables the turn limit', () => { + expect(getReplMaxTurnsWarning(0)).toBe(MAX_TURNS_UNLIMITED_WARNING) + expect(getReplMaxTurnsWarning(50)).toBeUndefined() + expect(getReplMaxTurnsWarning()).toBeUndefined() + }) + test('normalizeReplMaxTurns matches /config picker persistence', () => { expect(normalizeReplMaxTurns(200)).toBe(200) expect(normalizeReplMaxTurns('500')).toBe(500) @@ -241,4 +250,3 @@ describe('interactive REPL max-turn cap', () => { expect(body).toContain('maxTurns: options.maxTurns') }) }) - diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index da1a84873d..3c74e87934 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -2,6 +2,8 @@ import { getGlobalConfig } from './config.js' import { logForDebugging } from './debug.js' export const DEFAULT_REPL_MAX_TURNS = 50 +export const MAX_TURNS_UNLIMITED_WARNING = + 'Warning: --max-turns 0 disables the turn limit. Use with caution.' /** Preset values offered in `/config` (plus any current custom value). */ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const @@ -11,7 +13,7 @@ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const * Keep remote-backed sessions explicitly out of scope in this string. */ export const MAX_TURNS_CLI_DESCRIPTION = - 'Maximum number of agentic turns per prompt. In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' + 'Maximum number of agentic turns per prompt. Set to 0 for unlimited turns (use with caution). In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' /** * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when @@ -44,6 +46,10 @@ export function normalizeReplMaxTurns(value: unknown): number { return DEFAULT_REPL_MAX_TURNS } +export function getReplMaxTurnsWarning(maxTurns?: number): string | undefined { + return maxTurns === 0 ? MAX_TURNS_UNLIMITED_WARNING : undefined +} + function resolveConfiguredReplMaxTurns(): number { const configured = getGlobalConfig().replMaxTurns if (configured === undefined) { @@ -63,13 +69,17 @@ function resolveConfiguredReplMaxTurns(): number { * (connect/ssh/--remote) send prompts to a remote executor and are not * capped here. * - * Invalid explicit values fall through so a bad CLI parse cannot disable the - * interactive safety cap (unlike headless, where omitted maxTurns means no cap). + * An explicit CLI value of zero disables the cap. Other invalid explicit + * values fall through so a bad CLI parse cannot disable the interactive + * safety cap (unlike headless, where omitted maxTurns means no cap). * If OPENCLAUDE_MAX_TURNS is set but invalid, DEFAULT_REPL_MAX_TURNS is * used and lower layers (legacy env, /config) are not consulted — matching * OPENCLAUDE_MAX_RETRIES precedence. */ -export function resolveReplMaxTurns(maxTurns?: number): number { +export function resolveReplMaxTurns(maxTurns?: number): number | undefined { + if (maxTurns === 0) { + return undefined + } if ( typeof maxTurns === 'number' && Number.isSafeInteger(maxTurns) && From 29080823b69b3a801949b81a3d2bbd16ad0d8af9 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 15:54:46 -0700 Subject: [PATCH 09/34] fix(repl): scope unlimited-turn warning locally --- src/utils/replMaxTurns.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index 3c74e87934..b4e4974c7e 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -3,7 +3,7 @@ import { logForDebugging } from './debug.js' export const DEFAULT_REPL_MAX_TURNS = 50 export const MAX_TURNS_UNLIMITED_WARNING = - 'Warning: --max-turns 0 disables the turn limit. Use with caution.' + 'Warning: --max-turns 0 disables the local turn limit when applicable. Use with caution.' /** Preset values offered in `/config` (plus any current custom value). */ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const From f7135e7cac9c4c61a0d2f2c848830bd3ff063f38 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 16:24:41 -0700 Subject: [PATCH 10/34] fix(repl): preserve interactive turn caps across backgrounding --- src/main.tsx | 7 +------ src/query.ts | 6 +++++- src/screens/REPL.tsx | 28 +++++++++++++++++++++++----- src/screens/replMaxTurns.ts | 1 + src/screens/replMaxTurnsProp.test.ts | 9 +++++++++ 5 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index 78df7bd9f0..db09b8f928 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -123,7 +123,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { getReplMaxTurnsWarning, MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; +import { MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -986,11 +986,6 @@ async function run(): Promise { .option('--provider-env-file ', 'Load provider environment variables from a file before validation (repeatable; existing values win)', (val: string, prev: string[]) => [...prev, val], [] as string[]).action(async (prompt, options) => { profileCheckpoint('action_handler_start'); - const maxTurnsWarning = getReplMaxTurnsWarning(options.maxTurns); - if (maxTurnsWarning) { - process.stderr.write(chalk.yellow(`${maxTurnsWarning}\n`)); - } - // --bare = one-switch minimal mode. Sets SIMPLE so all the existing // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent // dir-walk). Must be set before setup() / any of the gated work runs. diff --git a/src/query.ts b/src/query.ts index 1e45817655..6a8a1cd0d3 100644 --- a/src/query.ts +++ b/src/query.ts @@ -444,6 +444,9 @@ export type QueryParams = { querySource: QuerySource maxOutputTokensOverride?: number maxTurns?: number + /** Continue an interrupted local REPL prompt without resetting its turn cap. */ + initialTurnCount?: number + onTurnCountChange?: (turnCount: number) => void skipCacheWrite?: boolean autoCompactTracking?: AutoCompactTrackingState onAutoCompactTrackingChange?: ( @@ -617,7 +620,7 @@ async function* queryLoop( hasAttemptedReactiveCompact: false, hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, - turnCount: 1, + turnCount: params.initialTurnCount ?? 1, continuationNudgeCount: 0, pendingToolUseSummary: undefined, transition: undefined, @@ -2947,6 +2950,7 @@ async function* queryLoop( // Each time we have tool results and are about to recurse, that's a turn const nextTurnCount = turnCount + 1 + params.onTurnCountChange?.(nextTurnCount) // Periodic task summary for `claude ps` — fires mid-turn so a // long-running agent still refreshes what it's working on. Gated diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 17372204d6..fb23a9c194 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -8,6 +8,7 @@ import { count } from '../utils/array.js'; import { dirname, join } from 'path'; import { tmpdir } from 'os'; import figures from 'figures'; +import chalk from 'chalk'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler import { useInput } from '../ink.js'; import { useSearchInput } from '../hooks/useSearchInput.js'; @@ -38,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { resolveReplMaxTurns } from './replMaxTurns.js'; +import { getReplMaxTurnsWarning, resolveReplMaxTurns } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -661,6 +662,15 @@ export function REPL({ // Resolve at query time so `/config` changes apply on the next prompt // without requiring a REPL remount. CLI prop still wins over env/config. const isRemoteSession = !!remoteSessionConfig; + const foregroundTurnCountRef = useRef(1); + const foregroundModelTurnStartedRef = useRef(false); + + useEffect(() => { + const warning = getReplMaxTurnsWarning(maxTurnsProp); + if (warning && !isRemoteSession && !directConnectConfig && !sshSession) { + process.stderr.write(chalk.yellow(`${warning}\n`)); + } + }, [maxTurnsProp, isRemoteSession, directConnectConfig, sshSession]); // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ // includes, and these were on the render path (hot during PageUp spam). @@ -2877,6 +2887,7 @@ export function REPL({ toolUseContext, fallbackModel, maxTurns: resolveReplMaxTurns(maxTurnsProp), + initialTurnCount: foregroundTurnCountRef.current + (foregroundModelTurnStartedRef.current ? 1 : 0), querySource: getQuerySourceForREPL(), autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), onAutoCompactTrackingChange: tracking => { @@ -3103,16 +3114,17 @@ export function REPL({ for await (const event of query({ messages: messagesIncludingNewMessages, requestOnlyMessages, - onModelRequestStart: interruptionCorrectionQueryId - ? () => { + onModelRequestStart: () => { + foregroundModelTurnStartedRef.current = true; + if (interruptionCorrectionQueryId) { interruptionCorrectionTracker.bindModelTurn({ shouldQuery, isInterruptionCorrectionEligible: true, queryId: interruptionCorrectionQueryId, }) onModelRequestStart?.() - } - : undefined, + } + }, systemPrompt, userContext, systemContext, @@ -3121,6 +3133,10 @@ export function REPL({ querySource: getQuerySourceForREPL(), fallbackModel, maxTurns: resolveReplMaxTurns(maxTurnsProp), + onTurnCountChange: turnCount => { + foregroundTurnCountRef.current = turnCount; + foregroundModelTurnStartedRef.current = false; + }, autoCompactTracking: queryAutoCompactTracking, onAutoCompactTrackingChange: tracking => { if (setAutoCompactTrackingForSessionIfUnchanged(querySessionId, expectedAutoCompactTracking, tracking)) { @@ -3185,6 +3201,8 @@ export function REPL({ } lifecycleTracker.clear(); const thisGeneration = startResult.generation; + foregroundTurnCountRef.current = 1; + foregroundModelTurnStartedRef.current = false; const queryContext = startResult.context; logQueryLifecycle('start', queryContext); logQueryLifecycle('guard_start', queryContext); diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index e5a2cd4e02..03e1e77bdd 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,5 +1,6 @@ export { DEFAULT_REPL_MAX_TURNS, + getReplMaxTurnsWarning, MAX_TURNS_CLI_DESCRIPTION, REPL_MAX_TURNS_OPTIONS, normalizeReplMaxTurns, diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index bba2aad272..2eb9963add 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -171,6 +171,13 @@ describe('interactive REPL max-turn cap', () => { expect(getReplMaxTurnsWarning()).toBeUndefined() }) + test('emits the unlimited warning only from a local REPL', () => { + const repl = readScreen('REPL.tsx') + const main = readSourceUp('main.tsx') + expect(repl).toContain('!isRemoteSession && !directConnectConfig && !sshSession') + expect(main).not.toContain('getReplMaxTurnsWarning') + }) + test('normalizeReplMaxTurns matches /config picker persistence', () => { expect(normalizeReplMaxTurns(200)).toBe(200) expect(normalizeReplMaxTurns('500')).toBe(500) @@ -191,7 +198,9 @@ describe('interactive REPL max-turn cap', () => { const background = objectBody(source, /queryParams:\s*\{/) expect(foreground).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') + expect(foreground).toContain('onTurnCountChange: turnCount =>') expect(background).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') + expect(background).toContain('initialTurnCount: foregroundTurnCountRef.current') }) test('Config panel exposes Max turns (interactive)', () => { From 012eda9266de63fab2e9fc691f9d5644fe379a60 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 17:08:58 -0700 Subject: [PATCH 11/34] fix(repl): preserve turn caps when backgrounding --- src/query.ts | 15 ++++++++++++++- src/query/agentStepLimit.test.ts | 24 ++++++++++++++++++++++++ src/screens/REPL.tsx | 20 ++++++++++---------- src/screens/replMaxTurnsProp.test.ts | 5 +++-- 4 files changed, 51 insertions(+), 13 deletions(-) diff --git a/src/query.ts b/src/query.ts index 6a8a1cd0d3..76f8ef3ddb 100644 --- a/src/query.ts +++ b/src/query.ts @@ -667,6 +667,18 @@ async function* queryLoop( // for what's included and why feature() gates are intentionally excluded. const config = buildQueryConfig() + // A resumed local REPL query can carry its prior turn count. Do not allow + // an already-exhausted handoff to make another provider request before the + // normal post-tool recursion guard has a chance to run. + if (maxTurns && state.turnCount > maxTurns) { + yield createAttachmentMessage({ + type: 'max_turns_reached', + maxTurns, + turnCount: state.turnCount, + }) + return { reason: 'max_turns', turnCount: state.turnCount } + } + // Fired once per user turn — the prompt is invariant across loop iterations, // so per-iteration firing would ask sideQuery the same question N times. // Consume point polls settledAt (never blocks). `using` disposes on all @@ -2950,7 +2962,6 @@ async function* queryLoop( // Each time we have tool results and are about to recurse, that's a turn const nextTurnCount = turnCount + 1 - params.onTurnCountChange?.(nextTurnCount) // Periodic task summary for `claude ps` — fires mid-turn so a // long-running agent still refreshes what it's working on. Gated @@ -2989,6 +3000,8 @@ async function* queryLoop( return { reason: 'max_turns', turnCount: nextTurnCount } } + params.onTurnCountChange?.(nextTurnCount) + if (!nextAgentStepLimit?.summaryRequested) { pendingToolFailureAdvisories = ( toolFailureLoopDecision.advisories ?? [] diff --git a/src/query/agentStepLimit.test.ts b/src/query/agentStepLimit.test.ts index ee9a8f639a..85a0725a24 100644 --- a/src/query/agentStepLimit.test.ts +++ b/src/query/agentStepLimit.test.ts @@ -132,6 +132,30 @@ async function drain(params: QueryParams): Promise<{ } describe('agent step limits', () => { + test('does not call the model when a resumed query is already over its turn cap', async () => { + let modelCalls = 0 + + const { yielded, returned } = await drain({ + ...makeParams(async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'must not run' }) + }), + maxTurns: 1, + initialTurnCount: 2, + }) + + expect(modelCalls).toBe(0) + expect(returned).toEqual({ reason: 'max_turns', turnCount: 2 }) + expect( + yielded.some( + message => + message.type === 'attachment' && + message.attachment.type === 'max_turns_reached' && + message.attachment.turnCount === 2, + ), + ).toBe(true) + }) + test('without a configured limit, tool use behavior is unchanged', async () => { echoCalls.length = 0 let modelCalls = 0 diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index fb23a9c194..f9931e2915 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -663,7 +663,6 @@ export function REPL({ // without requiring a REPL remount. CLI prop still wins over env/config. const isRemoteSession = !!remoteSessionConfig; const foregroundTurnCountRef = useRef(1); - const foregroundModelTurnStartedRef = useRef(false); useEffect(() => { const warning = getReplMaxTurnsWarning(maxTurnsProp); @@ -2845,6 +2844,10 @@ export function REPL({ // Session backgrounding (Ctrl+B to background/foreground) const handleBackgroundQuery = useCallback(() => { const backgroundSessionId = getSessionId(); + // Capture the old query's authoritative state before aborting it. The + // async setup below may otherwise observe a replacement prompt's refs. + const backgroundTurnCount = foregroundTurnCountRef.current; + const backgroundMaxTurns = resolveReplMaxTurns(maxTurnsProp); // Stop the foreground query so the background one takes over abortController?.abort('background'); // Aborting subagents may produce task-completed notifications. @@ -2886,8 +2889,8 @@ export function REPL({ canUseTool, toolUseContext, fallbackModel, - maxTurns: resolveReplMaxTurns(maxTurnsProp), - initialTurnCount: foregroundTurnCountRef.current + (foregroundModelTurnStartedRef.current ? 1 : 0), + maxTurns: backgroundMaxTurns, + initialTurnCount: backgroundTurnCount, querySource: getQuerySourceForREPL(), autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), onAutoCompactTrackingChange: tracking => { @@ -3114,17 +3117,16 @@ export function REPL({ for await (const event of query({ messages: messagesIncludingNewMessages, requestOnlyMessages, - onModelRequestStart: () => { - foregroundModelTurnStartedRef.current = true; - if (interruptionCorrectionQueryId) { + onModelRequestStart: interruptionCorrectionQueryId + ? () => { interruptionCorrectionTracker.bindModelTurn({ shouldQuery, isInterruptionCorrectionEligible: true, queryId: interruptionCorrectionQueryId, }) onModelRequestStart?.() - } - }, + } + : undefined, systemPrompt, userContext, systemContext, @@ -3135,7 +3137,6 @@ export function REPL({ maxTurns: resolveReplMaxTurns(maxTurnsProp), onTurnCountChange: turnCount => { foregroundTurnCountRef.current = turnCount; - foregroundModelTurnStartedRef.current = false; }, autoCompactTracking: queryAutoCompactTracking, onAutoCompactTrackingChange: tracking => { @@ -3202,7 +3203,6 @@ export function REPL({ lifecycleTracker.clear(); const thisGeneration = startResult.generation; foregroundTurnCountRef.current = 1; - foregroundModelTurnStartedRef.current = false; const queryContext = startResult.context; logQueryLifecycle('start', queryContext); logQueryLifecycle('guard_start', queryContext); diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 2eb9963add..2758b87e9b 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -199,8 +199,9 @@ describe('interactive REPL max-turn cap', () => { expect(foreground).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') expect(foreground).toContain('onTurnCountChange: turnCount =>') - expect(background).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') - expect(background).toContain('initialTurnCount: foregroundTurnCountRef.current') + expect(background).toContain('maxTurns: backgroundMaxTurns') + expect(background).toContain('initialTurnCount: backgroundTurnCount') + expect(background).not.toContain('foregroundModelTurnStartedRef') }) test('Config panel exposes Max turns (interactive)', () => { From 8eca6ad9fdf56f8cc33f643d5e6c83d3368bb19d Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 17:46:20 -0700 Subject: [PATCH 12/34] fix(repl): share turn budget across background handoff --- src/main.tsx | 10 +- src/query.ts | 105 ++++++++++-- src/query/agentStepLimit.test.ts | 234 +++++++++++++++++++++++++-- src/screens/REPL.tsx | 42 ++--- src/screens/replMaxTurns.ts | 23 +++ src/screens/replMaxTurnsProp.test.ts | 105 ++++++++++-- src/tasks/LocalMainSessionTask.ts | 6 +- src/utils/replMaxTurns.ts | 10 +- 8 files changed, 479 insertions(+), 56 deletions(-) diff --git a/src/main.tsx b/src/main.tsx index db09b8f928..57857573df 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -123,7 +123,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { MAX_TURNS_CLI_DESCRIPTION } from './utils/replMaxTurns.js'; +import { MAX_TURNS_CLI_DESCRIPTION, parseMaxTurnsCli } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -946,7 +946,13 @@ async function run(): Promise { } catch (error) { throw new InvalidArgumentError(errorMessage(error)); } - })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(Number)).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { + })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { + try { + return parseMaxTurnsCli(value); + } catch (error) { + throw new InvalidArgumentError(errorMessage(error)); + } + })).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { throw new Error('--max-budget-usd must be a positive number greater than 0'); diff --git a/src/query.ts b/src/query.ts index 76f8ef3ddb..eef52d6d60 100644 --- a/src/query.ts +++ b/src/query.ts @@ -52,6 +52,7 @@ import { logAntError, logForDebugging } from './utils/debug.js' import { getMissingToolResultAbortMessage, getQueryAbortSystemMessage, + normalizeAbortReason, shouldCreateUserInterruptionMessage, } from './utils/abortReasons.js' import { @@ -165,6 +166,20 @@ const taskSummaryModule = feature('BG_SESSIONS') : null /* eslint-enable @typescript-eslint/no-require-imports */ +function* emitAbortedStreaming( + signal: AbortSignal, +): Generator> { + const abortReason = signal.reason + const abortSystemMessage = getQueryAbortSystemMessage(abortReason) + if (abortSystemMessage) { + yield createSystemMessage(abortSystemMessage, 'warning') + } + if (shouldCreateUserInterruptionMessage(abortReason)) { + yield createUserInterruptionMessage({ toolUse: false }) + } + return { reason: 'aborted_streaming' } +} + function* yieldMissingToolResultBlocks( assistantMessages: AssistantMessage[], errorMessage: string, @@ -444,9 +459,11 @@ export type QueryParams = { querySource: QuerySource maxOutputTokensOverride?: number maxTurns?: number - /** Continue an interrupted local REPL prompt without resetting its turn cap. */ - initialTurnCount?: number - onTurnCountChange?: (turnCount: number) => void + /** + * Mutable per-prompt budget shared by query() calls that continue the same + * logical prompt (for example, when a local REPL query is backgrounded). + */ + turnBudget?: QueryTurnBudget skipCacheWrite?: boolean autoCompactTracking?: AutoCompactTrackingState onAutoCompactTrackingChange?: ( @@ -461,6 +478,17 @@ export type QueryParams = { deps?: QueryDeps } +export type QueryTurnBudget = { + readonly maxTurns: number | undefined + turnsStarted: number +} + +export function createQueryTurnBudget( + maxTurns?: number, +): QueryTurnBudget { + return { maxTurns, turnsStarted: 0 } +} + /** * `ultrathink_effort` is emitted while processing the current user input. * Its attachment is deliberately transient, but the request-level effort @@ -598,9 +626,14 @@ async function* queryLoop( canUseTool, fallbackModel, querySource, - maxTurns, skipCacheWrite, } = params + const maxTurns = params.turnBudget + ? params.turnBudget.maxTurns + : params.maxTurns + const initialTurnCount = params.turnBudget + ? params.turnBudget.turnsStarted + 1 + : 1 const deps = params.deps ?? productionDeps() const ultrathinkEffortForCurrentTurn = hasUltrathinkEffortForCurrentTurn( params.messages, @@ -620,7 +653,7 @@ async function* queryLoop( hasAttemptedReactiveCompact: false, hasAttemptedContextOverflowRecovery: false, hasAttemptedProviderFallback: false, - turnCount: params.initialTurnCount ?? 1, + turnCount: initialTurnCount, continuationNudgeCount: 0, pendingToolUseSummary: undefined, transition: undefined, @@ -662,14 +695,31 @@ async function* queryLoop( // at the new endpoint — KTD6 in the plan. let pinnedRouteProviderId: string | undefined = undefined const toolFailureGuardState = createToolFailureLoopGuardState() + // Identifies the turn this queryLoop invocation claimed in a shared budget. + // Retries for that turn are allowed; a different invocation that snapped the + // same next turn is stale and must not dispatch a duplicate provider call. + let reservedTurnCount: number | undefined = undefined // Snapshot immutable env/statsig/session state once at entry. See QueryConfig // for what's included and why feature() gates are intentionally excluded. const config = buildQueryConfig() - // A resumed local REPL query can carry its prior turn count. Do not allow - // an already-exhausted handoff to make another provider request before the - // normal post-tool recursion guard has a chance to run. + // Ctrl+B can abort the foreground while it is still preparing query + // context. Let the background continuation reserve the turn in that race; + // the aborted invocation never reached a provider request and must not + // consume the shared prompt budget. + if ( + params.turnBudget && + state.toolUseContext.abortController.signal.aborted + ) { + return yield* emitAbortedStreaming( + state.toolUseContext.abortController.signal, + ) + } + + // Reject an invocation that cannot start another provider turn. Do not + // reserve it here: context preparation below can await, and Ctrl+B may hand + // the prompt off before any provider request is dispatched. if (maxTurns && state.turnCount > maxTurns) { yield createAttachmentMessage({ type: 'max_turns_reached', @@ -1384,7 +1434,37 @@ async function* queryLoop( try { let streamingFallbackOccured = false queryCheckpoint('query_api_streaming_start') + // Context preparation above may yield long enough for Ctrl+B to + // abort this invocation. Only the query call that actually reaches + // provider dispatch owns this turn in the shared prompt budget. + if (toolUseContext.abortController.signal.aborted) { + return yield* emitAbortedStreaming( + toolUseContext.abortController.signal, + ) + } + if ( + params.turnBudget && + params.turnBudget.turnsStarted >= turnCount && + reservedTurnCount !== turnCount + ) { + return { reason: 'aborted_streaming' } + } params.onModelRequestStart?.() + // The callback is synchronous but may itself abort the request. + if (toolUseContext.abortController.signal.aborted) { + return yield* emitAbortedStreaming( + toolUseContext.abortController.signal, + ) + } + // Fallback attempts reuse the same turnCount, so this assignment is + // idempotent for retries and advances only for a new provider turn. + if ( + params.turnBudget && + params.turnBudget.turnsStarted < turnCount + ) { + params.turnBudget.turnsStarted = turnCount + reservedTurnCount = turnCount + } try { for await (const message of deps.callModel({ messages: prependUserContext( @@ -2665,7 +2745,12 @@ async function* queryLoop( } // Check maxTurns before returning when aborted const nextTurnCountOnAbort = turnCount + 1 - if (maxTurns && nextTurnCountOnAbort > maxTurns) { + if ( + maxTurns && + nextTurnCountOnAbort > maxTurns && + (!params.turnBudget || + normalizeAbortReason(abortReason) !== 'background') + ) { yield createAttachmentMessage({ type: 'max_turns_reached', maxTurns, @@ -3000,8 +3085,6 @@ async function* queryLoop( return { reason: 'max_turns', turnCount: nextTurnCount } } - params.onTurnCountChange?.(nextTurnCount) - if (!nextAgentStepLimit?.summaryRequested) { pendingToolFailureAdvisories = ( toolFailureLoopDecision.advisories ?? [] diff --git a/src/query/agentStepLimit.test.ts b/src/query/agentStepLimit.test.ts index 85a0725a24..9423200f3a 100644 --- a/src/query/agentStepLimit.test.ts +++ b/src/query/agentStepLimit.test.ts @@ -1,9 +1,14 @@ import { describe, expect, test } from 'bun:test' import { z } from 'zod/v4' -import { query, type QueryParams } from '../query.js' +import { + createQueryTurnBudget, + query, + type QueryParams, +} from '../query.js' import { buildTool, type Tools } from '../Tool.js' import type { QueryDeps } from './deps.js' +import { FallbackTriggeredError } from '../services/api/withRetry.js' import { createAssistantMessage, createUserMessage, @@ -132,19 +137,26 @@ async function drain(params: QueryParams): Promise<{ } describe('agent step limits', () => { - test('does not call the model when a resumed query is already over its turn cap', async () => { + test('shares one turn budget across query calls for the same prompt', async () => { let modelCalls = 0 + const turnBudget = createQueryTurnBudget(1) + const callModel = async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'done' }) + } + + await drain({ + ...makeParams(callModel), + turnBudget, + }) const { yielded, returned } = await drain({ - ...makeParams(async function* () { - modelCalls++ - yield createAssistantMessage({ content: 'must not run' }) - }), - maxTurns: 1, - initialTurnCount: 2, + ...makeParams(callModel), + turnBudget, }) - expect(modelCalls).toBe(0) + expect(modelCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) expect(returned).toEqual({ reason: 'max_turns', turnCount: 2 }) expect( yielded.some( @@ -156,6 +168,210 @@ describe('agent step limits', () => { ).toBe(true) }) + test('provider fallback retries do not consume another shared turn', async () => { + let modelCalls = 0 + const turnBudget = createQueryTurnBudget(1) + const params = makeParams(async function* () { + modelCalls++ + if (modelCalls === 1) { + throw new FallbackTriggeredError('primary-model', 'fallback-model') + } + yield createAssistantMessage({ content: 'completed on fallback' }) + }) + params.fallbackModel = 'fallback-model' + + const first = await drain({ ...params, turnBudget }) + const second = await drain({ + ...makeParams(async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'must not run' }) + }), + turnBudget, + }) + + expect(first.returned).toEqual({ reason: 'completed' }) + expect(second.returned).toEqual({ reason: 'max_turns', turnCount: 2 }) + expect(modelCalls).toBe(2) + expect(turnBudget.turnsStarted).toBe(1) + }) + + test('concurrent query calls cannot claim the same shared turn', async () => { + let modelCalls = 0 + let preparationArrivals = 0 + let releasePreparation!: () => void + const preparationBarrier = new Promise(resolve => { + releasePreparation = resolve + }) + const turnBudget = createQueryTurnBudget(1) + const callModel = async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'one claimant' }) + } + const makeConcurrentParams = (): QueryParams => { + const params = makeParams(callModel) + params.deps!.autocompact = async () => { + preparationArrivals++ + if (preparationArrivals === 2) releasePreparation() + await preparationBarrier + return { + wasCompacted: false, + compactionResult: undefined, + consecutiveFailures: undefined, + } + } + return { ...params, turnBudget } + } + + const results = await Promise.all([ + drain(makeConcurrentParams()), + drain(makeConcurrentParams()), + ]) + + expect(modelCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) + expect(results.map(result => result.returned.reason).sort()).toEqual([ + 'aborted_streaming', + 'completed', + ]) + }) + + test('does not charge a handoff aborted before its first request', async () => { + let modelCalls = 0 + const turnBudget = createQueryTurnBudget(1) + const callModel = async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'continued in background' }) + } + const foregroundParams = makeParams(callModel) + foregroundParams.toolUseContext.abortController.abort('background') + + const foreground = await drain({ + ...foregroundParams, + turnBudget, + }) + const background = await drain({ + ...makeParams(callModel), + turnBudget, + }) + + expect(foreground.returned).toEqual({ reason: 'aborted_streaming' }) + expect(background.returned).toEqual({ reason: 'completed' }) + expect(modelCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) + }) + + test('does not charge a handoff aborted while preparing its first request', async () => { + let modelCalls = 0 + const turnBudget = createQueryTurnBudget(1) + const callModel = async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'continued in background' }) + } + const foregroundParams = makeParams(callModel) + const foregroundAbortController = + foregroundParams.toolUseContext.abortController + foregroundParams.deps!.autocompact = async () => { + foregroundAbortController.abort('background') + return { + wasCompacted: false, + compactionResult: undefined, + consecutiveFailures: undefined, + } + } + + const foreground = await drain({ + ...foregroundParams, + turnBudget, + }) + const background = await drain({ + ...makeParams(callModel), + turnBudget, + }) + + expect(foreground.returned).toEqual({ reason: 'aborted_streaming' }) + expect(background.returned).toEqual({ reason: 'completed' }) + expect(modelCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) + }) + + test('background handoff emits the final-turn cap only once', async () => { + let modelCalls = 0 + let foregroundAbortController: AbortController + const backgroundingTool = buildTool({ + name: 'Background', + inputSchema: z.object({}), + maxResultSizeChars: Infinity, + async description() { + return 'Background the query' + }, + async prompt() { + return '' + }, + async call() { + foregroundAbortController.abort('background') + return { data: 'backgrounded' } + }, + mapToolResultToToolResultBlockParam(content, toolUseID) { + return { + type: 'tool_result', + tool_use_id: toolUseID, + content: String(content), + } + }, + renderToolUseMessage() { + return null + }, + renderToolResultMessage() { + return null + }, + }) + const foregroundParams = makeParams( + async function* () { + modelCalls++ + yield createAssistantMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_background_1', + name: 'Background', + input: {}, + }, + ], + }) + }, + [backgroundingTool], + ) + foregroundAbortController = + foregroundParams.toolUseContext.abortController + const turnBudget = createQueryTurnBudget(1) + + const foreground = await drain({ + ...foregroundParams, + turnBudget, + }) + const background = await drain({ + ...makeParams(async function* () { + modelCalls++ + yield createAssistantMessage({ content: 'must not run' }) + }), + turnBudget, + }) + const capAttachments = [...foreground.yielded, ...background.yielded].filter( + message => + message.type === 'attachment' && + message.attachment.type === 'max_turns_reached', + ) + + expect(foreground.returned).toEqual({ reason: 'aborted_tools' }) + expect(background.returned).toEqual({ + reason: 'max_turns', + turnCount: 2, + }) + expect(modelCalls).toBe(1) + expect(capAttachments).toHaveLength(1) + expect(background.yielded).toContain(capAttachments[0]) + }) + test('without a configured limit, tool use behavior is unchanged', async () => { echoCalls.length = 0 let modelCalls = 0 diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index f9931e2915..b85a3024b5 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -39,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { getReplMaxTurnsWarning, resolveReplMaxTurns } from './replMaxTurns.js'; +import { claimBackgroundTurnBudget, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -143,7 +143,7 @@ import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; import { queryCheckpoint, logQueryProfileReport, clearQueryProfile } from '../utils/queryProfiler.js'; import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js'; -import { query } from '../query.js'; +import { createQueryTurnBudget, query, type QueryTurnBudget } from '../query.js'; import type { AutoCompactTrackingState } from '../services/compact/autoCompact.js'; import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; import { getQuerySourceForREPL } from '../utils/promptCategory.js'; @@ -662,7 +662,8 @@ export function REPL({ // Resolve at query time so `/config` changes apply on the next prompt // without requiring a REPL remount. CLI prop still wins over env/config. const isRemoteSession = !!remoteSessionConfig; - const foregroundTurnCountRef = useRef(1); + const foregroundTurnBudgetRef = useRef(null); + const backgroundHandoffStartedRef = useRef(false); useEffect(() => { const warning = getReplMaxTurnsWarning(maxTurnsProp); @@ -2844,10 +2845,11 @@ export function REPL({ // Session backgrounding (Ctrl+B to background/foreground) const handleBackgroundQuery = useCallback(() => { const backgroundSessionId = getSessionId(); - // Capture the old query's authoritative state before aborting it. The - // async setup below may otherwise observe a replacement prompt's refs. - const backgroundTurnCount = foregroundTurnCountRef.current; - const backgroundMaxTurns = resolveReplMaxTurns(maxTurnsProp); + // Transfer the exact per-prompt budget before aborting. Re-resolving the + // limit or copying a callback-maintained count here can reset or skew the + // cap while the foreground query is winding down. + const backgroundTurnBudget = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); + if (!backgroundTurnBudget) return; // Stop the foreground query so the background one takes over abortController?.abort('background'); // Aborting subagents may produce task-completed notifications. @@ -2889,8 +2891,7 @@ export function REPL({ canUseTool, toolUseContext, fallbackModel, - maxTurns: backgroundMaxTurns, - initialTurnCount: backgroundTurnCount, + turnBudget: backgroundTurnBudget, querySource: getQuerySourceForREPL(), autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), onAutoCompactTrackingChange: tracking => { @@ -2902,7 +2903,7 @@ export function REPL({ agentDefinition: mainThreadAgentDefinition }); })(); - }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, maxTurnsProp]); + }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel]); const { handleBackgroundSession } = useSessionBackgrounding({ @@ -2977,7 +2978,7 @@ export function REPL({ void removeTranscriptMessage(tombstonedMessage.uuid); }, setStreamingThinking, undefined, onStreamingText); }, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]); - const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, effort?: EffortValue, queryLifecycle?: QueryLifecycleOperationTracker, requestOnlyMessages: MessageType[] = [], interruptionCorrectionQueryId?: string, onModelRequestStart?: () => void) => { + const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, turnBudget: QueryTurnBudget, effort?: EffortValue, queryLifecycle?: QueryLifecycleOperationTracker, requestOnlyMessages: MessageType[] = [], interruptionCorrectionQueryId?: string, onModelRequestStart?: () => void) => { // Prepare IDE integration for new prompt. Read mcpClients fresh from // store — useManageMCPConnections may have populated it since the // render that captured this closure (same pattern as computeTools). @@ -3134,10 +3135,7 @@ export function REPL({ toolUseContext, querySource: getQuerySourceForREPL(), fallbackModel, - maxTurns: resolveReplMaxTurns(maxTurnsProp), - onTurnCountChange: turnCount => { - foregroundTurnCountRef.current = turnCount; - }, + turnBudget, autoCompactTracking: queryAutoCompactTracking, onAutoCompactTrackingChange: tracking => { if (setAutoCompactTrackingForSessionIfUnchanged(querySessionId, expectedAutoCompactTracking, tracking)) { @@ -3163,7 +3161,7 @@ export function REPL({ // Signal that a query turn has completed successfully await onTurnComplete?.(messagesRef.current); - }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, maxTurnsProp, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard, interruptionCorrectionTracker]); + }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard, interruptionCorrectionTracker]); const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue, isInterruptionCorrectionEligible = false, onModelRequestStart?: () => void): Promise => { // If this is a teammate, mark them as active when starting a turn if (isAgentSwarmsEnabled()) { @@ -3202,7 +3200,9 @@ export function REPL({ } lifecycleTracker.clear(); const thisGeneration = startResult.generation; - foregroundTurnCountRef.current = 1; + backgroundHandoffStartedRef.current = false; + const turnBudget = createQueryTurnBudget(resolveReplMaxTurns(maxTurnsProp)); + foregroundTurnBudgetRef.current = turnBudget; const queryContext = startResult.context; logQueryLifecycle('start', queryContext); logQueryLifecycle('guard_start', queryContext); @@ -3262,7 +3262,7 @@ export function REPL({ } if (!preflightVetoed) { modelTurnStarted = true; - await onQueryImpl(latestMessages, persistentNewMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, effort, lifecycleTracker, requestOnlyMessages, isInterruptionCorrectionEligible ? queryContext.queryId : undefined, onModelRequestStart); + await onQueryImpl(latestMessages, persistentNewMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, turnBudget, effort, lifecycleTracker, requestOnlyMessages, isInterruptionCorrectionEligible ? queryContext.queryId : undefined, onModelRequestStart); } if (preflightVetoed) { return false; @@ -3276,6 +3276,10 @@ export function REPL({ } throw error; } finally { + // The ref is only an ownership marker for the currently foregrounded + // prompt. A background handoff already captured the budget object, so + // clear the ref on every terminal path without disturbing a newer query. + releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudget); // A provider response can hand off to tools before the assistant turn // finishes. Keep correction ownership through that work (and retries), // then clear it only when this query reaches its terminal cleanup. @@ -3434,7 +3438,7 @@ export function REPL({ } } } - }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete]); + }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, maxTurnsProp]); // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 03e1e77bdd..1337f7a04d 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,3 +1,5 @@ +import type { QueryTurnBudget } from '../query.js' + export { DEFAULT_REPL_MAX_TURNS, getReplMaxTurnsWarning, @@ -6,3 +8,24 @@ export { normalizeReplMaxTurns, resolveReplMaxTurns, } from '../utils/replMaxTurns.js' + +type MutableRef = { current: T } + +export function claimBackgroundTurnBudget( + budgetRef: MutableRef, + handoffStartedRef: MutableRef, +): QueryTurnBudget | null { + if (!budgetRef.current || handoffStartedRef.current) return null + handoffStartedRef.current = true + return budgetRef.current +} + +export function releaseForegroundTurnBudget( + budgetRef: MutableRef, + handoffStartedRef: MutableRef, + ownedBudget: QueryTurnBudget, +): void { + if (budgetRef.current !== ownedBudget) return + budgetRef.current = null + handoffStartedRef.current = false +} diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 2758b87e9b..d2f6e6223d 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,10 +1,20 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, describe, expect, spyOn, test } from 'bun:test' import { readFileSync } from 'node:fs' import { join } from 'node:path' -import { Command as CommanderCommand, Option } from '@commander-js/extra-typings' +import { + Command as CommanderCommand, + InvalidArgumentError, + Option, +} from '@commander-js/extra-typings' +import { createQueryTurnBudget } from '../query.js' +import { + claimBackgroundTurnBudget, + releaseForegroundTurnBudget, +} from './replMaxTurns.js' import { DEFAULT_GLOBAL_CONFIG, GLOBAL_CONFIG_KEYS, + getGlobalConfig, isGlobalConfigKey, saveGlobalConfig, } from '../utils/config.js' @@ -14,15 +24,18 @@ import { MAX_TURNS_CLI_DESCRIPTION, MAX_TURNS_UNLIMITED_WARNING, normalizeReplMaxTurns, + parseMaxTurnsCli, REPL_MAX_TURNS_OPTIONS, resolveReplMaxTurns, } from '../utils/replMaxTurns.js' +import * as debug from '../utils/debug.js' const screenDir = import.meta.dirname const ENV_KEYS = ['OPENCLAUDE_MAX_TURNS', 'CLAUDE_CODE_MAX_TURNS'] as const const savedEnv: Partial> = {} +const savedReplMaxTurns = getGlobalConfig().replMaxTurns function readScreen(name: string): string { return readFileSync(join(screenDir, name), 'utf8') @@ -64,9 +77,18 @@ function createMaxTurnsCliProgram(): CommanderCommand { const program = new CommanderCommand() program .name('openclaude') + .exitOverride() .addOption( new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser( - Number, + value => { + try { + return parseMaxTurnsCli(value) + } catch (error) { + throw new InvalidArgumentError( + error instanceof Error ? error.message : String(error), + ) + } + }, ), ) .action(() => {}) @@ -82,7 +104,7 @@ afterEach(() => { process.env[key] = previous } } - setReplMaxTurnsConfig(undefined) + setReplMaxTurnsConfig(savedReplMaxTurns) }) for (const key of ENV_KEYS) { @@ -128,6 +150,23 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) }) + test('does not include an invalid environment value in debug logs', () => { + clearTurnEnv() + const invalidValue = 'private-value-that-must-not-be-logged' + process.env.OPENCLAUDE_MAX_TURNS = invalidValue + const logSpy = spyOn(debug, 'logForDebugging').mockImplementation(() => {}) + + try { + expect(resolveReplMaxTurns()).toBe(DEFAULT_REPL_MAX_TURNS) + expect(logSpy).toHaveBeenCalledTimes(1) + const logged = logSpy.mock.calls.flat().join(' ') + expect(logged).toContain('OPENCLAUDE_MAX_TURNS has an invalid value') + expect(logged).not.toContain(invalidValue) + } finally { + logSpy.mockRestore() + } + }) + test('explicit CLI cap wins over environment overrides', () => { clearTurnEnv() process.env.OPENCLAUDE_MAX_TURNS = '200' @@ -192,16 +231,34 @@ describe('interactive REPL max-turn cap', () => { expect(REPL_MAX_TURNS_OPTIONS).toEqual([50, 100, 200, 500]) }) - test('passes the resolved cap to foreground and background queries', () => { - const source = readScreen('REPL.tsx') - const foreground = objectBody(source, /for await \(const event of query\(\{/) - const background = objectBody(source, /queryParams:\s*\{/) + test('background budget handoff preserves identity and is one-shot', () => { + const budget = createQueryTurnBudget(50) + const budgetRef = { current: budget } + const handoffStartedRef = { current: false } + + expect( + claimBackgroundTurnBudget(budgetRef, handoffStartedRef), + ).toBe(budget) + expect( + claimBackgroundTurnBudget(budgetRef, handoffStartedRef), + ).toBeNull() + + const newerBudget = createQueryTurnBudget(100) + budgetRef.current = newerBudget + handoffStartedRef.current = false + releaseForegroundTurnBudget(budgetRef, handoffStartedRef, budget) + expect(budgetRef.current).toBe(newerBudget) + + releaseForegroundTurnBudget(budgetRef, handoffStartedRef, newerBudget) + expect(budgetRef.current).toBeNull() + expect(handoffStartedRef.current).toBe(false) + }) - expect(foreground).toContain('maxTurns: resolveReplMaxTurns(maxTurnsProp)') - expect(foreground).toContain('onTurnCountChange: turnCount =>') - expect(background).toContain('maxTurns: backgroundMaxTurns') - expect(background).toContain('initialTurnCount: backgroundTurnCount') - expect(background).not.toContain('foregroundModelTurnStartedRef') + test('background tasks retain a max-turn terminal attachment', () => { + const backgroundTask = readSourceUp(join('tasks', 'LocalMainSessionTask.ts')) + expect(backgroundTask).toContain( + "event.attachment.type === 'max_turns_reached'", + ) }) test('Config panel exposes Max turns (interactive)', () => { @@ -245,6 +302,28 @@ describe('interactive REPL max-turn cap', () => { expect(resolveReplMaxTurns(parsed)).toBe(200) }) + test('Commander --max-turns rejects values that could disable the cap accidentally', () => { + expect(parseMaxTurnsCli('0')).toBe(0) + expect(parseMaxTurnsCli('200')).toBe(200) + for (const invalid of ['', ' ', 'nope', '-3', '2.5', 'Infinity']) { + expect(() => parseMaxTurnsCli(invalid)).toThrow( + '--max-turns must be a non-negative integer', + ) + } + }) + + test('Commander formats invalid --max-turns as an option error', async () => { + const program = createMaxTurnsCliProgram() + await expect( + program.parseAsync(['node', 'openclaude', '--max-turns', 'nope'], { + from: 'node', + }), + ).rejects.toMatchObject({ + code: 'commander.invalidArgument', + exitCode: 1, + }) + }) + test('main imports the shared --max-turns description and wires sessionConfig', () => { // main.tsx is hard to boot in unit tests; assert the import so the // shared constant cannot be referenced without being bundled, plus the diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 57763f3b3a..8708c781f2 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -402,7 +402,11 @@ export function startBackgroundSession({ if ( event.type !== 'user' && event.type !== 'assistant' && - event.type !== 'system' + event.type !== 'system' && + !( + event.type === 'attachment' && + event.attachment.type === 'max_turns_reached' + ) ) { continue } diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index b4e4974c7e..746e907b9e 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -15,6 +15,14 @@ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const export const MAX_TURNS_CLI_DESCRIPTION = 'Maximum number of agentic turns per prompt. Set to 0 for unlimited turns (use with caution). In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' +export function parseMaxTurnsCli(value: string): number { + const parsed = value.trim() === '' ? Number.NaN : Number(value) + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error('--max-turns must be a non-negative integer') + } + return parsed +} + /** * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when * the new variable is unset/empty. Invalid, zero, negative, non-integer, or @@ -97,7 +105,7 @@ export function resolveReplMaxTurns(maxTurns?: number): number | undefined { // Match OPENCLAUDE_MAX_RETRIES: set-but-invalid uses the default and does // not fall through to legacy env or /config; surface a debug diagnostic. logForDebugging( - `OPENCLAUDE_MAX_TURNS Invalid value "${openClaudeRaw}" (using default: ${DEFAULT_REPL_MAX_TURNS})`, + `OPENCLAUDE_MAX_TURNS has an invalid value (using default: ${DEFAULT_REPL_MAX_TURNS})`, ) return DEFAULT_REPL_MAX_TURNS } From 3c7db934803dc9df5c879aa1e8849482ea5931d8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 19:28:33 -0700 Subject: [PATCH 13/34] fix(repl): reserve turns at provider dispatch --- src/query.ts | 205 +++++++++++++--------- src/query/agentStepLimit.test.ts | 190 +++++++++++++++++++- src/query/requestOnlyMessages.test.ts | 5 +- src/screens/REPL.tsx | 181 ++++++++++++------- src/screens/replMaxTurns.ts | 66 ++++++- src/screens/replMaxTurnsProp.test.ts | 45 +++-- src/services/api/claude.lifecycle.test.ts | 76 ++++++++ src/services/api/claude.ts | 88 ++++++---- src/tasks/LocalMainSessionTask.test.ts | 67 +++++++ src/tasks/LocalMainSessionTask.ts | 134 ++++++++++---- 10 files changed, 823 insertions(+), 234 deletions(-) create mode 100644 src/tasks/LocalMainSessionTask.test.ts diff --git a/src/query.ts b/src/query.ts index eef52d6d60..0ecd7da4d3 100644 --- a/src/query.ts +++ b/src/query.ts @@ -166,9 +166,32 @@ const taskSummaryModule = feature('BG_SESSIONS') : null /* eslint-enable @typescript-eslint/no-require-imports */ -function* emitAbortedStreaming( +async function cleanupComputerUseAtTerminal( + toolUseContext: ToolUseContext, +): Promise { + // feature() must remain the direct condition so external builds eliminate + // the native Computer Use dependency at bundle time. + if (feature('CHICAGO_MCP')) { + if (toolUseContext.agentId) return + try { + const { cleanupComputerUseAfterTurn } = await import( + './utils/computerUse/cleanup.js' + ) + await cleanupComputerUseAfterTurn(toolUseContext) + } catch { + // Failures are silent — this is dogfooding cleanup, not critical path. + } + } +} + +async function* emitAbortedStreaming( signal: AbortSignal, -): Generator> { + toolUseContext: ToolUseContext, +): AsyncGenerator< + Message, + Extract +> { + await cleanupComputerUseAtTerminal(toolUseContext) const abortReason = signal.reason const abortSystemMessage = getQueryAbortSystemMessage(abortReason) if (abortSystemMessage) { @@ -180,6 +203,34 @@ function* emitAbortedStreaming( return { reason: 'aborted_streaming' } } +function* emitAbortedToolsAfterCleanup( + signal: AbortSignal, + maxTurns: number | undefined, + nextTurnCount: number, + hasSharedTurnBudget: boolean, +): Generator> { + const abortReason = signal.reason + const abortSystemMessage = getQueryAbortSystemMessage(abortReason) + if (abortSystemMessage) { + yield createSystemMessage(abortSystemMessage, 'warning') + } + if (shouldCreateUserInterruptionMessage(abortReason)) { + yield createUserInterruptionMessage({ toolUse: true }) + } + if ( + maxTurns && + nextTurnCount > maxTurns && + (!hasSharedTurnBudget || normalizeAbortReason(abortReason) !== 'background') + ) { + yield createAttachmentMessage({ + type: 'max_turns_reached', + maxTurns, + turnCount: nextTurnCount, + }) + } + return { reason: 'aborted_tools' } +} + function* yieldMissingToolResultBlocks( assistantMessages: AssistantMessage[], errorMessage: string, @@ -714,6 +765,7 @@ async function* queryLoop( ) { return yield* emitAbortedStreaming( state.toolUseContext.abortController.signal, + state.toolUseContext, ) } @@ -721,6 +773,7 @@ async function* queryLoop( // reserve it here: context preparation below can await, and Ctrl+B may hand // the prompt off before any provider request is dispatched. if (maxTurns && state.turnCount > maxTurns) { + await cleanupComputerUseAtTerminal(state.toolUseContext) yield createAttachmentMessage({ type: 'max_turns_reached', maxTurns, @@ -1434,37 +1487,11 @@ async function* queryLoop( try { let streamingFallbackOccured = false queryCheckpoint('query_api_streaming_start') - // Context preparation above may yield long enough for Ctrl+B to - // abort this invocation. Only the query call that actually reaches - // provider dispatch owns this turn in the shared prompt budget. - if (toolUseContext.abortController.signal.aborted) { - return yield* emitAbortedStreaming( - toolUseContext.abortController.signal, - ) - } - if ( - params.turnBudget && - params.turnBudget.turnsStarted >= turnCount && - reservedTurnCount !== turnCount - ) { - return { reason: 'aborted_streaming' } - } - params.onModelRequestStart?.() - // The callback is synchronous but may itself abort the request. - if (toolUseContext.abortController.signal.aborted) { - return yield* emitAbortedStreaming( - toolUseContext.abortController.signal, - ) - } - // Fallback attempts reuse the same turnCount, so this assignment is - // idempotent for retries and advances only for a new provider turn. - if ( - params.turnBudget && - params.turnBudget.turnsStarted < turnCount - ) { - params.turnBudget.turnsStarted = turnCount - reservedTurnCount = turnCount - } + // queryModel performs provider-specific asynchronous preparation. + // Claim the turn from its callback immediately before the actual + // request is dispatched, not merely when callModel is entered. + let providerDispatchRejected = false + let providerRequestStarted = false try { for await (const message of deps.callModel({ messages: prependUserContext( @@ -1508,6 +1535,41 @@ async function* queryLoop( ), queryTracking, queryLifecycle: toolUseContext.queryLifecycle, + onProviderRequestStart: () => { + if (toolUseContext.abortController.signal.aborted) { + providerDispatchRejected = true + return false + } + // Retries reuse this turn's reservation, but they must still + // prove the foreground owns dispatch after any asynchronous + // credential refresh or client recreation. + if (providerRequestStarted) return true + if ( + params.turnBudget && + params.turnBudget.turnsStarted >= turnCount && + reservedTurnCount !== turnCount + ) { + providerDispatchRejected = true + return false + } + params.onModelRequestStart?.() + // The lifecycle callback is synchronous but may itself abort. + if (toolUseContext.abortController.signal.aborted) { + providerDispatchRejected = true + return false + } + // Fallback attempts reuse turnCount. The local reservation + // makes retries idempotent while rejecting a stale claimant. + if ( + params.turnBudget && + params.turnBudget.turnsStarted < turnCount + ) { + params.turnBudget.turnsStarted = turnCount + reservedTurnCount = turnCount + } + providerRequestStarted = true + return true + }, // Explicit /effort selection wins. When it is unset, carry the // current turn's ultrathink attachment through to the API client // so OpenAI-compatible providers receive reasoning_effort=high @@ -1719,8 +1781,17 @@ async function* queryLoop( } } } + if (providerDispatchRejected) { + if (toolUseContext.abortController.signal.aborted) { + return yield* emitAbortedStreaming( + toolUseContext.abortController.signal, + toolUseContext, + ) + } + return { reason: 'aborted_streaming' } + } } finally { - params.onModelRequestEnd?.() + if (providerRequestStarted) params.onModelRequestEnd?.() } queryCheckpoint('query_api_streaming_end') @@ -1947,16 +2018,7 @@ async function* queryLoop( // chicago MCP: auto-unhide + lock release on interrupt. Same cleanup // as the natural turn-end path in stopHooks.ts. Main thread only — // see stopHooks.ts for the subagent-releasing-main's-lock rationale. - if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { - try { - const { cleanupComputerUseAfterTurn } = await import( - './utils/computerUse/cleanup.js' - ) - await cleanupComputerUseAfterTurn(toolUseContext) - } catch { - // Failures are silent — this is dogfooding cleanup, not critical path - } - } + await cleanupComputerUseAtTerminal(toolUseContext) const abortSystemMessage = getQueryAbortSystemMessage(abortReason) if (abortSystemMessage) { @@ -2719,45 +2781,16 @@ async function* queryLoop( // We were aborted during tool calls if (toolUseContext.abortController.signal.aborted) { - const abortReason = toolUseContext.abortController.signal.reason // chicago MCP: auto-unhide + lock release when aborted mid-tool-call. // This is the most likely Ctrl+C path for CU (e.g. slow screenshot). // Main thread only — see stopHooks.ts for the subagent rationale. - if (feature('CHICAGO_MCP') && !toolUseContext.agentId) { - try { - const { cleanupComputerUseAfterTurn } = await import( - './utils/computerUse/cleanup.js' - ) - await cleanupComputerUseAfterTurn(toolUseContext) - } catch { - // Failures are silent — this is dogfooding cleanup, not critical path - } - } - const abortSystemMessage = getQueryAbortSystemMessage(abortReason) - if (abortSystemMessage) { - yield createSystemMessage(abortSystemMessage, 'warning') - } - - if (shouldCreateUserInterruptionMessage(abortReason)) { - yield createUserInterruptionMessage({ - toolUse: true, - }) - } - // Check maxTurns before returning when aborted - const nextTurnCountOnAbort = turnCount + 1 - if ( - maxTurns && - nextTurnCountOnAbort > maxTurns && - (!params.turnBudget || - normalizeAbortReason(abortReason) !== 'background') - ) { - yield createAttachmentMessage({ - type: 'max_turns_reached', - maxTurns, - turnCount: nextTurnCountOnAbort, - }) - } - return { reason: 'aborted_tools' } + await cleanupComputerUseAtTerminal(toolUseContext) + return yield* emitAbortedToolsAfterCleanup( + toolUseContext.abortController.signal, + maxTurns, + turnCount + 1, + params.turnBudget !== undefined, + ) } // If a hook indicated to prevent continuation, stop here @@ -3077,6 +3110,18 @@ async function* queryLoop( nextTurnCount > maxTurns && !nextAgentStepLimit?.summaryRequested ) { + await cleanupComputerUseAtTerminal(toolUseContext) + // Attachment/memory/skill collection above can await after the earlier + // post-tool abort check. Re-check immediately before emitting the cap so + // a Ctrl+B handoff cannot make both owners persist the terminal record. + if (toolUseContext.abortController.signal.aborted) { + return yield* emitAbortedToolsAfterCleanup( + toolUseContext.abortController.signal, + maxTurns, + nextTurnCount, + params.turnBudget !== undefined, + ) + } yield createAttachmentMessage({ type: 'max_turns_reached', maxTurns, diff --git a/src/query/agentStepLimit.test.ts b/src/query/agentStepLimit.test.ts index 9423200f3a..b1f8c4d7eb 100644 --- a/src/query/agentStepLimit.test.ts +++ b/src/query/agentStepLimit.test.ts @@ -17,6 +17,7 @@ import { import { asSystemPrompt } from '../utils/systemPromptType.js' import { countToolUses } from '../tools/AgentTool/agentToolUtils.js' import { AGENT_STEP_LIMIT_TOOL_RESULT_PREFIX } from './agentStepLimit.js' +import { dequeueAll, enqueue } from '../utils/messageQueueManager.js' const echoCalls: string[] = [] @@ -102,6 +103,10 @@ function makeParams( tools: Tools = [], agentStepLimit?: { maxSteps: number; agentType: string }, ): QueryParams { + const dispatchingCallModel: QueryDeps['callModel'] = async function* (input) { + if (input.options.onProviderRequestStart?.() === false) return + yield* callModel(input) + } return { messages: [createUserMessage({ content: 'inspect' })], systemPrompt: asSystemPrompt([]), @@ -112,7 +117,7 @@ function makeParams( querySource: 'agent:builtin:general-purpose', ...(agentStepLimit ? { agentStepLimit } : {}), deps: { - callModel, + callModel: dispatchingCallModel, microcompact: async messages => ({ messages }), autocompact: async () => ({ compactionResult: null, @@ -195,6 +200,30 @@ describe('agent step limits', () => { expect(turnBudget.turnsStarted).toBe(1) }) + test('a retry cannot dispatch after foreground ownership is aborted', async () => { + let providerCalls = 0 + const turnBudget = createQueryTurnBudget(1) + const params = makeParams(async function* () {}) + const abortController = params.toolUseContext.abortController + params.deps!.callModel = async function* ({ options }) { + if (options.onProviderRequestStart?.() === false) return + providerCalls++ + + // Simulate an abort during asynchronous retry preparation. The second + // ownership check must reject even though this turn is already reserved. + abortController.abort('background') + if (options.onProviderRequestStart?.() === false) return + providerCalls++ + yield createAssistantMessage({ content: 'stale retry' }) + } + + const result = await drain({ ...params, turnBudget }) + + expect(result.returned).toEqual({ reason: 'aborted_streaming' }) + expect(providerCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) + }) + test('concurrent query calls cannot claim the same shared turn', async () => { let modelCalls = 0 let preparationArrivals = 0 @@ -212,7 +241,21 @@ describe('agent step limits', () => { params.deps!.autocompact = async () => { preparationArrivals++ if (preparationArrivals === 2) releasePreparation() - await preparationBarrier + let timeout: ReturnType | undefined + try { + await Promise.race([ + preparationBarrier, + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error('preparation barrier timed out')), + 5_000, + ) + timeout.unref?.() + }), + ]) + } finally { + if (timeout !== undefined) clearTimeout(timeout) + } return { wasCompacted: false, compactionResult: undefined, @@ -294,6 +337,50 @@ describe('agent step limits', () => { expect(turnBudget.turnsStarted).toBe(1) }) + test('does not charge a handoff aborted during provider preparation', async () => { + let providerCalls = 0 + let releaseProviderPreparation!: () => void + let providerPreparationStarted!: () => void + const providerPreparation = new Promise(resolve => { + releaseProviderPreparation = resolve + }) + const providerPreparationEntry = new Promise(resolve => { + providerPreparationStarted = resolve + }) + const turnBudget = createQueryTurnBudget(1) + const foregroundParams = makeParams(async function* () {}) + const foregroundAbortController = + foregroundParams.toolUseContext.abortController + foregroundParams.deps!.callModel = async function* (input) { + providerPreparationStarted() + await providerPreparation + if (input.options.onProviderRequestStart?.() === false) return + providerCalls++ + yield createAssistantMessage({ content: 'stale foreground request' }) + } + + const foregroundPromise = drain({ + ...foregroundParams, + turnBudget, + }) + await providerPreparationEntry + foregroundAbortController.abort('background') + releaseProviderPreparation() + const foreground = await foregroundPromise + const background = await drain({ + ...makeParams(async function* () { + providerCalls++ + yield createAssistantMessage({ content: 'continued in background' }) + }), + turnBudget, + }) + + expect(foreground.returned).toEqual({ reason: 'aborted_streaming' }) + expect(background.returned).toEqual({ reason: 'completed' }) + expect(providerCalls).toBe(1) + expect(turnBudget.turnsStarted).toBe(1) + }) + test('background handoff emits the final-turn cap only once', async () => { let modelCalls = 0 let foregroundAbortController: AbortController @@ -372,6 +459,105 @@ describe('agent step limits', () => { expect(background.yielded).toContain(capAttachments[0]) }) + test('late background handoff after attachments still emits one final-turn cap', async () => { + dequeueAll() + let foregroundAbortController: AbortController + const queueingTool = buildTool({ + name: 'QueuePrompt', + inputSchema: z.object({}), + maxResultSizeChars: Infinity, + async description() { + return 'Queue a prompt during tool execution' + }, + async prompt() { + return '' + }, + async call() { + enqueue({ value: 'queued during tool execution', mode: 'prompt' }) + return { data: 'queued' } + }, + mapToolResultToToolResultBlockParam(content, toolUseID) { + return { + type: 'tool_result', + tool_use_id: toolUseID, + content: String(content), + } + }, + renderToolUseMessage() { + return null + }, + renderToolResultMessage() { + return null + }, + }) + const foregroundParams = makeParams( + async function* () { + yield createAssistantMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_queue_prompt_1', + name: 'QueuePrompt', + input: {}, + }, + ], + }) + }, + [queueingTool], + ) + foregroundParams.querySource = 'repl_main_thread' + foregroundAbortController = + foregroundParams.toolUseContext.abortController + const turnBudget = createQueryTurnBudget(1) + const foregroundGenerator = query({ ...foregroundParams, turnBudget }) + const foregroundYielded: any[] = [] + + try { + let foregroundReturned: any + while (true) { + const next = await foregroundGenerator.next() + if (next.done) { + foregroundReturned = next.value + break + } + foregroundYielded.push(next.value) + if ( + next.value.type === 'attachment' && + next.value.attachment.type === 'queued_command' + ) { + // This yield is after the post-tool abort check and immediately + // before the terminal max-turn check that used to double-emit. + foregroundAbortController.abort('background') + } + } + + const background = await drain({ + ...makeParams(async function* () { + yield createAssistantMessage({ content: 'must not run' }) + }), + turnBudget, + }) + const capAttachments = [ + ...foregroundYielded, + ...background.yielded, + ].filter( + message => + message.type === 'attachment' && + message.attachment.type === 'max_turns_reached', + ) + + expect(foregroundReturned).toEqual({ reason: 'aborted_tools' }) + expect(background.returned).toEqual({ + reason: 'max_turns', + turnCount: 2, + }) + expect(capAttachments).toHaveLength(1) + expect(background.yielded).toContain(capAttachments[0]) + } finally { + dequeueAll() + } + }) + test('without a configured limit, tool use behavior is unchanged', async () => { echoCalls.length = 0 let modelCalls = 0 diff --git a/src/query/requestOnlyMessages.test.ts b/src/query/requestOnlyMessages.test.ts index 37da355769..22731269c8 100644 --- a/src/query/requestOnlyMessages.test.ts +++ b/src/query/requestOnlyMessages.test.ts @@ -292,10 +292,11 @@ test('keeps an explicit effort selection over ultrathink', async () => { expect(requestEffort).toBe('low') }) -test('scopes model-request lifecycle callbacks to callModel', async () => { +test('scopes model-request lifecycle callbacks to provider dispatch', async () => { const events: string[] = [] const params = baseParams( - async function* () { + async function* ({ options }) { + if (options.onProviderRequestStart?.() === false) return yield createAssistantMessage({ content: 'done' }) }, async () => ({ wasCompacted: false }), diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index b85a3024b5..7ef80adac0 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -39,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { claimBackgroundTurnBudget, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns } from './replMaxTurns.js'; +import { claimBackgroundTurnBudget, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -143,7 +143,8 @@ import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; import { queryCheckpoint, logQueryProfileReport, clearQueryProfile } from '../utils/queryProfiler.js'; import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection } from '../types/message.js'; -import { createQueryTurnBudget, query, type QueryTurnBudget } from '../query.js'; +import { query, type QueryTurnBudget } from '../query.js'; +import type { Terminal as QueryTerminal } from '../query/transitions.js'; import type { AutoCompactTrackingState } from '../services/compact/autoCompact.js'; import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; import { getQuerySourceForREPL } from '../utils/promptCategory.js'; @@ -662,7 +663,7 @@ export function REPL({ // Resolve at query time so `/config` changes apply on the next prompt // without requiring a REPL remount. CLI prop still wins over env/config. const isRemoteSession = !!remoteSessionConfig; - const foregroundTurnBudgetRef = useRef(null); + const foregroundTurnBudgetRef = useRef(null); const backgroundHandoffStartedRef = useRef(false); useEffect(() => { @@ -2848,61 +2849,95 @@ export function REPL({ // Transfer the exact per-prompt budget before aborting. Re-resolving the // limit or copying a callback-maintained count here can reset or skew the // cap while the foreground query is winding down. - const backgroundTurnBudget = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); - if (!backgroundTurnBudget) return; - // Stop the foreground query so the background one takes over - abortController?.abort('background'); - // Aborting subagents may produce task-completed notifications. - // Clear task notifications so the queue processor doesn't immediately - // start a new foreground query; forward them to the background session. - const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); - void (async () => { - const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel); - const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]); - const systemPrompt = buildEffectiveSystemPrompt({ - mainThreadAgentDefinition, - toolUseContext, - customSystemPrompt, - defaultSystemPrompt, - appendSystemPrompt - }); - toolUseContext.renderedSystemPrompt = systemPrompt; - const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []); - const notificationMessages = notificationAttachments.map(createAttachmentMessage); - - // Deduplicate: if the query loop already yielded a notification into - // messagesRef before we removed it from the queue, skip duplicates. - // We use prompt text for dedup because source_uuid is not set on - // task-notification QueuedCommands (enqueuePendingNotification callers - // don't pass uuid), so it would always be undefined. - const existingPrompts = new Set(); - for (const m of messagesRef.current) { - if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { - existingPrompts.add(m.attachment.prompt); + const backgroundHandoff = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); + if (!backgroundHandoff) return; + startBackgroundSession({ + prepare: async backgroundAbortController => { + // The foreground owns transcript completion. Wait until its abort path + // has appended terminal tool results before snapshotting continuation + // state, while still honoring a task stop during that wait. + const shouldContinue = await waitForForegroundTurnBudgetSettlement(backgroundHandoff, backgroundAbortController.signal); + if (shouldContinue === null) { + throw backgroundAbortController.signal.reason; } - } - const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); - startBackgroundSession({ - messages: [...messagesRef.current, ...uniqueNotifications], - queryParams: { - systemPrompt, - userContext, - systemContext, - canUseTool, + if (!shouldContinue) return null; + // Claim foreground-produced notifications immediately after the + // settlement decision, before any async context preparation can let + // another queue consumer take ownership. + const pendingNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); + let notificationOwnershipActive = true; + const restoreNotificationsIfUnsent = () => { + if (!notificationOwnershipActive) return; + notificationOwnershipActive = false; + for (const notification of pendingNotifications) { + enqueue(notification); + } + }; + const toolUseContext = getToolUseContext(messagesRef.current, [], backgroundAbortController, mainLoopModel); + const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]).catch(error => { + restoreNotificationsIfUnsent(); + throw error; + }); + if (backgroundAbortController.signal.aborted) { + restoreNotificationsIfUnsent(); + throw backgroundAbortController.signal.reason; + } + const systemPrompt = buildEffectiveSystemPrompt({ + mainThreadAgentDefinition, toolUseContext, - fallbackModel, - turnBudget: backgroundTurnBudget, - querySource: getQuerySourceForREPL(), - autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), - onAutoCompactTrackingChange: tracking => { - setAutoCompactTrackingForSession(backgroundSessionId, tracking); + customSystemPrompt, + defaultSystemPrompt, + appendSystemPrompt + }); + toolUseContext.renderedSystemPrompt = systemPrompt; + const notificationAttachments = await getQueuedCommandAttachments(pendingNotifications).catch(error => { + restoreNotificationsIfUnsent(); + throw error; + }); + if (backgroundAbortController.signal.aborted) { + restoreNotificationsIfUnsent(); + throw backgroundAbortController.signal.reason; + } + const notificationMessages = notificationAttachments.map(createAttachmentMessage); + + // Deduplicate: if the query loop already yielded a notification into + // messagesRef before we removed it from the queue, skip duplicates. + // We use prompt text for dedup because source_uuid is not set on + // task-notification QueuedCommands (enqueuePendingNotification callers + // don't pass uuid), so it would always be undefined. + const existingPrompts = new Set(); + for (const m of messagesRef.current) { + if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { + existingPrompts.add(m.attachment.prompt); } - }, - description: terminalTitle, - setAppState, - agentDefinition: mainThreadAgentDefinition - }); - })(); + } + const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); + return { + messages: [...messagesRef.current, ...uniqueNotifications], + restoreNotificationsIfUnsent, + queryParams: { + systemPrompt, + userContext, + systemContext, + canUseTool, + toolUseContext, + fallbackModel, + turnBudget: backgroundHandoff.budget, + querySource: getQuerySourceForREPL(), + autoCompactTracking: getAutoCompactTrackingForSession(backgroundSessionId), + onAutoCompactTrackingChange: tracking => { + setAutoCompactTrackingForSession(backgroundSessionId, tracking); + } + } + }; + }, + description: terminalTitle, + setAppState, + agentDefinition: mainThreadAgentDefinition + }); + // The successor task now exists and owns preparation failures, so the + // foreground can be aborted without leaving an unowned continuation gap. + abortController?.abort('background'); }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel]); const { handleBackgroundSession @@ -2978,7 +3013,7 @@ export function REPL({ void removeTranscriptMessage(tombstonedMessage.uuid); }, setStreamingThinking, undefined, onStreamingText); }, [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText]); - const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, turnBudget: QueryTurnBudget, effort?: EffortValue, queryLifecycle?: QueryLifecycleOperationTracker, requestOnlyMessages: MessageType[] = [], interruptionCorrectionQueryId?: string, onModelRequestStart?: () => void) => { + const onQueryImpl = useCallback(async (messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, queryGeneration: number, turnBudget: QueryTurnBudget, effort?: EffortValue, queryLifecycle?: QueryLifecycleOperationTracker, requestOnlyMessages: MessageType[] = [], interruptionCorrectionQueryId?: string, onModelRequestStart?: () => void): Promise => { // Prepare IDE integration for new prompt. Read mcpClients fresh from // store — useManageMCPConnections may have populated it since the // render that captured this closure (same pattern as computeTools). @@ -3115,7 +3150,7 @@ export function REPL({ resetTurnToolDuration(); resetTurnClassifierDuration(); let expectedAutoCompactTracking = queryAutoCompactTracking; - for await (const event of query({ + const queryGenerator = query({ messages: messagesIncludingNewMessages, requestOnlyMessages, onModelRequestStart: interruptionCorrectionQueryId @@ -3142,9 +3177,23 @@ export function REPL({ expectedAutoCompactTracking = tracking; } } - })) { - queryGuard.registerActivity(`query_event:${event.type}`, queryGeneration); - onQueryEvent(event); + }); + let queryTerminal: QueryTerminal; + let generatorDone = false; + try { + while (true) { + const next = await queryGenerator.next(); + if (next.done) { + generatorDone = true; + queryTerminal = next.value; + break; + } + const event = next.value; + queryGuard.registerActivity(`query_event:${event.type}`, queryGeneration); + onQueryEvent(event); + } + } finally { + if (!generatorDone) await queryGenerator.return(undefined as never); } if (isBuddyEnabled()) { void fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => prev.companionReaction === reaction ? prev : { @@ -3161,6 +3210,7 @@ export function REPL({ // Signal that a query turn has completed successfully await onTurnComplete?.(messagesRef.current); + return queryTerminal; }, [initialMcpClients, resetLoadingState, getToolUseContext, toolPermissionContext, setAppState, customSystemPrompt, onTurnComplete, appendSystemPrompt, canUseTool, mainThreadAgentDefinition, onQueryEvent, sessionTitle, titleDisabled, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, setAutoCompactTrackingForSessionIfUnchanged, queryGuard, interruptionCorrectionTracker]); const onQuery = useCallback(async (newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue, isInterruptionCorrectionEligible = false, onModelRequestStart?: () => void): Promise => { // If this is a teammate, mark them as active when starting a turn @@ -3201,12 +3251,14 @@ export function REPL({ lifecycleTracker.clear(); const thisGeneration = startResult.generation; backgroundHandoffStartedRef.current = false; - const turnBudget = createQueryTurnBudget(resolveReplMaxTurns(maxTurnsProp)); - foregroundTurnBudgetRef.current = turnBudget; + const turnBudgetHandoff = createForegroundTurnBudgetHandoff(resolveReplMaxTurns(maxTurnsProp)); + const turnBudget = turnBudgetHandoff.budget; + foregroundTurnBudgetRef.current = turnBudgetHandoff; const queryContext = startResult.context; logQueryLifecycle('start', queryContext); logQueryLifecycle('guard_start', queryContext); let didThrow = false; + let queryTerminal: QueryTerminal | undefined; let preflightVetoed = false; let modelTurnStarted = false; let hasInterruptionCorrectionRequestOnlyMessage = false; @@ -3262,7 +3314,7 @@ export function REPL({ } if (!preflightVetoed) { modelTurnStarted = true; - await onQueryImpl(latestMessages, persistentNewMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, turnBudget, effort, lifecycleTracker, requestOnlyMessages, isInterruptionCorrectionEligible ? queryContext.queryId : undefined, onModelRequestStart); + queryTerminal = await onQueryImpl(latestMessages, persistentNewMessages, abortController, shouldQuery, additionalAllowedTools, mainLoopModelParam, thisGeneration, turnBudget, effort, lifecycleTracker, requestOnlyMessages, isInterruptionCorrectionEligible ? queryContext.queryId : undefined, onModelRequestStart); } if (preflightVetoed) { return false; @@ -3279,7 +3331,8 @@ export function REPL({ // The ref is only an ownership marker for the currently foregrounded // prompt. A background handoff already captured the budget object, so // clear the ref on every terminal path without disturbing a newer query. - releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudget); + const shouldContinueBackground = abortController.signal.reason === 'background' && (queryTerminal === undefined || queryTerminal.reason === 'aborted_streaming' || queryTerminal.reason === 'aborted_tools'); + releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); // A provider response can hand off to tools before the assistant turn // finishes. Keep correction ownership through that work (and retries), // then clear it only when this query reaches its terminal cleanup. diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 1337f7a04d..0701190b41 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -1,4 +1,7 @@ -import type { QueryTurnBudget } from '../query.js' +import { + createQueryTurnBudget, + type QueryTurnBudget, +} from '../query.js' export { DEFAULT_REPL_MAX_TURNS, @@ -11,21 +14,72 @@ export { type MutableRef = { current: T } +export type ForegroundTurnBudgetHandoff = { + budget: QueryTurnBudget + settled: Promise + settle: (shouldContinue: boolean) => void +} + +export function createForegroundTurnBudgetHandoff( + maxTurns?: number, +): ForegroundTurnBudgetHandoff { + let resolveSettled!: (shouldContinue: boolean) => void + let isSettled = false + const settled = new Promise(resolve => { + resolveSettled = resolve + }) + return { + budget: createQueryTurnBudget(maxTurns), + settled, + settle: shouldContinue => { + if (isSettled) return + isSettled = true + resolveSettled(shouldContinue) + }, + } +} + +export async function waitForForegroundTurnBudgetSettlement( + handoff: ForegroundTurnBudgetHandoff, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null + + let resolveAborted!: () => void + const aborted = new Promise(resolve => { + resolveAborted = resolve + }) + const onAbort = () => resolveAborted() + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([ + handoff.settled, + aborted.then(() => null), + ]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + export function claimBackgroundTurnBudget( - budgetRef: MutableRef, + budgetRef: MutableRef, handoffStartedRef: MutableRef, -): QueryTurnBudget | null { +): ForegroundTurnBudgetHandoff | null { if (!budgetRef.current || handoffStartedRef.current) return null handoffStartedRef.current = true return budgetRef.current } export function releaseForegroundTurnBudget( - budgetRef: MutableRef, + budgetRef: MutableRef, handoffStartedRef: MutableRef, - ownedBudget: QueryTurnBudget, + ownedHandoff: ForegroundTurnBudgetHandoff, + shouldContinue: boolean, ): void { - if (budgetRef.current !== ownedBudget) return + // Always release waiters for this prompt, even if a newer prompt replaced + // the foreground ref before the stale finally ran. + ownedHandoff.settle(shouldContinue) + if (budgetRef.current !== ownedHandoff) return budgetRef.current = null handoffStartedRef.current = false } diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index d2f6e6223d..b692a30de0 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -6,10 +6,11 @@ import { InvalidArgumentError, Option, } from '@commander-js/extra-typings' -import { createQueryTurnBudget } from '../query.js' import { claimBackgroundTurnBudget, + createForegroundTurnBudgetHandoff, releaseForegroundTurnBudget, + waitForForegroundTurnBudgetSettlement, } from './replMaxTurns.js' import { DEFAULT_GLOBAL_CONFIG, @@ -231,34 +232,48 @@ describe('interactive REPL max-turn cap', () => { expect(REPL_MAX_TURNS_OPTIONS).toEqual([50, 100, 200, 500]) }) - test('background budget handoff preserves identity and is one-shot', () => { - const budget = createQueryTurnBudget(50) - const budgetRef = { current: budget } + test('background budget handoff preserves identity, settlement, and one-shot ownership', async () => { + const handoff = createForegroundTurnBudgetHandoff(50) + const budgetRef = { current: handoff } const handoffStartedRef = { current: false } expect( claimBackgroundTurnBudget(budgetRef, handoffStartedRef), - ).toBe(budget) + ).toBe(handoff) + expect(handoff.budget.maxTurns).toBe(50) expect( claimBackgroundTurnBudget(budgetRef, handoffStartedRef), ).toBeNull() - const newerBudget = createQueryTurnBudget(100) - budgetRef.current = newerBudget + const newerHandoff = createForegroundTurnBudgetHandoff(100) + budgetRef.current = newerHandoff handoffStartedRef.current = false - releaseForegroundTurnBudget(budgetRef, handoffStartedRef, budget) - expect(budgetRef.current).toBe(newerBudget) - - releaseForegroundTurnBudget(budgetRef, handoffStartedRef, newerBudget) + releaseForegroundTurnBudget(budgetRef, handoffStartedRef, handoff, true) + await expect(handoff.settled).resolves.toBe(true) + expect(budgetRef.current).toBe(newerHandoff) + + releaseForegroundTurnBudget( + budgetRef, + handoffStartedRef, + newerHandoff, + false, + ) + await expect(newerHandoff.settled).resolves.toBe(false) expect(budgetRef.current).toBeNull() expect(handoffStartedRef.current).toBe(false) }) - test('background tasks retain a max-turn terminal attachment', () => { - const backgroundTask = readSourceUp(join('tasks', 'LocalMainSessionTask.ts')) - expect(backgroundTask).toContain( - "event.attachment.type === 'max_turns_reached'", + test('a stopped background task does not remain blocked on foreground settlement', async () => { + const handoff = createForegroundTurnBudgetHandoff(50) + const abortController = new AbortController() + const wait = waitForForegroundTurnBudgetSettlement( + handoff, + abortController.signal, ) + + abortController.abort('task stopped') + + await expect(wait).resolves.toBeNull() }) test('Config panel exposes Max turns (interactive)', () => { diff --git a/src/services/api/claude.lifecycle.test.ts b/src/services/api/claude.lifecycle.test.ts index 8f63f50d73..a975dd445a 100644 --- a/src/services/api/claude.lifecycle.test.ts +++ b/src/services/api/claude.lifecycle.test.ts @@ -337,6 +337,49 @@ afterEach(() => { }) describe('Claude API lifecycle tracking', () => { + test('checks provider-request ownership immediately before dispatch', async () => { + setClientTestEnv() + process.env.OPENCLAUDE_MAX_RETRIES = '0' + const queryLifecycle = new QueryLifecycleOperationTracker() + const events: string[] = [] + let permissionContextReads = 0 + const fetchOverride: FetchOverride = async () => { + events.push('fetch') + return makeJsonResponse(makeBetaMessage()) + } + + const generator = queryModelWithStreaming({ + messages: [ + { + type: 'user', + uuid: '00000000-0000-0000-0000-000000000001', + timestamp: '2026-06-17T00:00:00.000Z', + message: { role: 'user', content: 'hello' }, + } as Message, + ], + systemPrompt: asSystemPrompt([]), + thinkingConfig: { type: 'disabled' }, + tools: [], + signal: new AbortController().signal, + options: { + ...makeOptions(queryLifecycle), + fetchOverride, + getToolPermissionContext: async () => { + permissionContextReads++ + return getEmptyToolPermissionContext() + }, + onProviderRequestStart: () => { + events.push('ownership-check') + return false + }, + }, + }) + + expect(await drainGenerator(generator)).toBeUndefined() + expect(events).toEqual(['ownership-check']) + expect(permissionContextReads).toBe(0) + }) + test('ends a failed streaming dispatch before retry backoff is reported', async () => { setClientTestEnv() process.env.OPENCLAUDE_MAX_RETRIES = '1' @@ -652,6 +695,7 @@ describe('Claude API lifecycle tracking', () => { ), ) + if (result === null) throw new Error('expected non-streaming response') expect(result.id).toBe('msg-lifecycle-test') expect(requestSnapshots).toHaveLength(1) expect(requestSnapshots[0]?.apiCalls).toHaveLength(1) @@ -662,6 +706,38 @@ describe('Claude API lifecycle tracking', () => { expect(queryLifecycle.snapshot().apiCalls).toEqual([]) }) + test('non-streaming fallback checks ownership before dispatch', async () => { + setClientTestEnv() + const queryLifecycle = new QueryLifecycleOperationTracker() + let fetchCalls = 0 + const fetchOverride: FetchOverride = async () => { + fetchCalls++ + return makeJsonResponse(makeBetaMessage()) + } + + const result = await drainGenerator( + executeNonStreamingRequest( + { model: 'claude-lifecycle-test', source: 'sdk', fetchOverride }, + { + model: 'claude-lifecycle-test', + thinkingConfig: { type: 'disabled' }, + signal: new AbortController().signal, + querySource: 'sdk', + }, + makeParams, + () => {}, + () => {}, + null, + queryLifecycle, + () => false, + ), + ) + + expect(result).toBeNull() + expect(fetchCalls).toBe(0) + expect(queryLifecycle.snapshot().apiCalls).toEqual([]) + }) + test('clears non-streaming fallback lifecycle entries after request errors', async () => { setClientTestEnv() process.env.OPENCLAUDE_MAX_RETRIES = '0' diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 3a778cb65b..91d2dd7646 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -737,6 +737,11 @@ export type Options = { providerOverride?: { model: string; baseURL: string; apiKey: string } queryLifecycle?: QueryLifecycleOperationTracker messageNormalizationTools?: Tools + /** + * Synchronous ownership check invoked immediately before an outbound + * provider request. Returning false skips the request without retrying. + */ + onProviderRequestStart?: () => boolean } export async function queryModelWithoutStreaming({ @@ -913,7 +918,8 @@ export async function* executeNonStreamingRequest( */ originatingRequestId?: string | null, queryLifecycle?: QueryLifecycleOperationTracker, -): AsyncGenerator { + onProviderRequestStart?: () => boolean, +): AsyncGenerator { const fallbackTimeoutMs = getNonstreamingFallbackTimeoutMs() const generator = withRetry( () => @@ -927,6 +933,7 @@ export async function* executeNonStreamingRequest( }), async (anthropic, attempt, context) => { const start = Date.now() + if (onProviderRequestStart?.() === false) return null const retryParams = paramsFromContext(context) captureRequest(retryParams) onAttempt(attempt, start, retryParams.max_tokens) @@ -1000,7 +1007,7 @@ export async function* executeNonStreamingRequest( } } while (!e.done) - return e.value as BetaMessage + return e.value as BetaMessage | null } /** @@ -1913,36 +1920,6 @@ async function* queryModel( } } - // Compute log scalars synchronously so the fire-and-forget .then() closure - // captures only primitives instead of paramsFromContext's full closure scope - // (messagesForAPI, system, allTools, betas — the entire request-building - // context), which would otherwise be pinned until the promise resolves. - { - const queryParams = paramsFromContext({ - model: options.model, - thinkingConfig, - }) - const logMessagesLength = queryParams.messages.length - const logBetas = useBetas ? (queryParams.betas ?? []) : [] - const logThinkingType = queryParams.thinking?.type ?? 'disabled' - const logEffortValue = queryParams.output_config?.effort - void options.getToolPermissionContext().then(permissionContext => { - logAPIQuery({ - model: options.model, - messagesLength: logMessagesLength, - temperature: options.temperatureOverride ?? 1, - betas: logBetas, - permissionMode: permissionContext.mode, - querySource: options.querySource, - queryTracking: options.queryTracking, - thinkingType: logThinkingType, - effortValue: logEffortValue, - fastMode: isFastMode, - previousRequestId, - }) - }) - } - const newMessages: AssistantMessage[] = [] let ttftMs = 0 let partialMessage: BetaMessage | undefined = undefined @@ -1957,10 +1934,11 @@ async function* queryModel( let research: unknown = undefined let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back let isAdvisorInProgress = false + let apiQueryLogged = false try { queryCheckpoint('query_client_creation_start') - const generator = withRetry( + const generator = withRetry | null>( () => getAnthropicClient({ maxRetries: 0, // Disabled auto-retry in favor of manual implementation @@ -1981,11 +1959,44 @@ async function* queryModel( // client_creation_start is meaningful on attempt 1. queryCheckpoint('query_client_creation_end') + // Keep this immediately adjacent to the SDK call below. query.ts uses + // it to atomically reserve a shared foreground/background turn only + // after every asynchronous provider-preparation step has completed. + if (options.onProviderRequestStart?.() === false) { + return null + } + + // Everything below is synchronous until the SDK request is created, + // so ownership cannot change between this request build and dispatch. const params = paramsFromContext(context) captureAPIRequest(params, options.querySource) // Capture for bug reports - maxOutputTokens = params.max_tokens + if (!apiQueryLogged) { + apiQueryLogged = true + // Capture primitives only: the fire-and-forget permission lookup + // must not retain the full request-building closure. + const logMessagesLength = params.messages.length + const logBetas = useBetas ? (params.betas ?? []) : [] + const logThinkingType = params.thinking?.type ?? 'disabled' + const logEffortValue = params.output_config?.effort + void options.getToolPermissionContext().then(permissionContext => { + logAPIQuery({ + model: options.model, + messagesLength: logMessagesLength, + temperature: options.temperatureOverride ?? 1, + betas: logBetas, + permissionMode: permissionContext.mode, + querySource: options.querySource, + queryTracking: options.queryTracking, + thinkingType: logThinkingType, + effortValue: logEffortValue, + fastMode: isFastMode, + previousRequestId, + }) + }) + } + // Fire immediately before the fetch is dispatched. .withResponse() below // awaits until response headers arrive, so this MUST be before the await // or the "Network TTFB" phase measurement is wrong. @@ -2053,10 +2064,11 @@ async function* queryModel( e = await generator.next() // yield API error messages (the stream has a 'controller' property, error messages don't) - if (!('controller' in e.value)) { + if (!e.done && !('controller' in e.value)) { yield e.value } } while (!e.done) + if (e.value === null) return stream = e.value as Stream // reset state @@ -2876,8 +2888,11 @@ async function* queryModel( params => captureAPIRequest(params, options.querySource), streamRequestId, options.queryLifecycle, + options.onProviderRequestStart, ) + if (result === null) return + const m: AssistantMessage = { message: { ...result, @@ -2996,8 +3011,11 @@ async function* queryModel( params => captureAPIRequest(params, options.querySource), failedRequestId, options.queryLifecycle, + options.onProviderRequestStart, ) + if (result === null) return + const m: AssistantMessage = { message: { ...result, diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts new file mode 100644 index 0000000000..abada4ad85 --- /dev/null +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, test } from 'bun:test' + +import type { QueryParams } from '../query.js' +import type { Terminal } from '../query/transitions.js' +import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' +import type { Message } from '../types/message.js' +import { createAttachmentMessage } from '../utils/attachments.js' +import { dequeueAll } from '../utils/messageQueueManager.js' +import { + isMainSessionTask, + startBackgroundSession, +} from './LocalMainSessionTask.js' + +async function waitFor( + condition: () => boolean, + description: string, +): Promise { + const deadline = Date.now() + 2_000 + while (!condition()) { + if (Date.now() >= deadline) { + throw new Error(`Timed out waiting for ${description}`) + } + await new Promise(resolve => setTimeout(resolve, 5)) + } +} + +afterEach(() => { + dequeueAll() +}) + +describe('LocalMainSessionTask', () => { + test('retains a max-turn terminal attachment in task messages', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + const cap = createAttachmentMessage({ + type: 'max_turns_reached', + maxTurns: 1, + turnCount: 2, + }) + const queryImpl = async function* (): AsyncGenerator { + yield cap + return { reason: 'max_turns', turnCount: 2 } + } + + const taskId = startBackgroundSession({ + description: 'cap retention test', + setAppState, + queryImpl: queryImpl as typeof import('../query.js').query, + prepare: async () => ({ + messages: [], + queryParams: {} as Omit, + }), + }) + + await waitFor( + () => state.tasks[taskId]?.status === 'completed', + 'background task completion', + ) + + const task = state.tasks[taskId] + expect(isMainSessionTask(task)).toBe(true) + if (!isMainSessionTask(task)) throw new Error('expected main-session task') + expect(task.messages).toEqual([cap]) + }) +}) diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 8708c781f2..023ae441d1 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -48,7 +48,11 @@ import { getTaskOutputPath, initTaskOutputAsSymlink, } from '../utils/task/diskOutput.js' -import { registerTask, updateTaskState } from '../utils/task/framework.js' +import { + PANEL_GRACE_MS, + registerTask, + updateTaskState, +} from '../utils/task/framework.js' import type { LocalAgentTaskState } from './LocalAgentTask/LocalAgentTask.js' // Main session tasks use LocalAgentTaskState with agentType='main-session' @@ -88,14 +92,14 @@ function generateMainSessionTaskId(): string { * @param setAppState - State setter function * @param mainThreadAgentDefinition - Optional agent definition if running with --agent * @param existingAbortController - Optional abort controller to reuse (for backgrounding an active query) - * @returns Object with task ID and abort signal for stopping the background query + * @returns Object with task ID and the controller that owns background cancellation */ export function registerMainSessionTask( description: string, setAppState: SetAppState, mainThreadAgentDefinition?: AgentDefinition, existingAbortController?: AbortController, -): { taskId: string; abortSignal: AbortSignal } { +): { taskId: string; abortController: AbortController } { const taskId = generateMainSessionTaskId() // Link output to an isolated per-task transcript file (same layout as @@ -157,7 +161,7 @@ export function registerMainSessionTask( return prev }) - return { taskId, abortSignal: abortController.signal } + return { taskId, abortController } } /** @@ -331,34 +335,79 @@ type ToolActivity = { /** * Start a fresh background session with the given messages. * - * Spawns an independent query() call with the current messages and registers it - * as a background task. The caller's foreground query continues running normally. + * Registers the background task synchronously, then prepares and starts an + * independent query() call. Registration precedes preparation so the caller + * can safely abort the foreground once this function returns. */ export function startBackgroundSession({ - messages, - queryParams, + prepare, description, setAppState, agentDefinition, + queryImpl = query, }: { - messages: Message[] - queryParams: Omit + prepare: (abortController: AbortController) => Promise< + | { + messages: Message[] + restoreNotificationsIfUnsent?: () => void + queryParams: Omit + } + | null + > description: string setAppState: SetAppState agentDefinition?: AgentDefinition + queryImpl?: typeof query }): string { - const { taskId, abortSignal } = registerMainSessionTask( + const { taskId, abortController } = registerMainSessionTask( description, setAppState, agentDefinition, ) + const abortSignal = abortController.signal + let providerRequestStarted = false + let restoreNotificationsIfUnsent: (() => void) | undefined + + const restorePreDispatchNotifications = (): void => { + if (providerRequestStarted) return + const restore = restoreNotificationsIfUnsent + restoreNotificationsIfUnsent = undefined + restore?.() + } - // Persist the pre-backgrounding conversation to the task's isolated - // transcript so TaskOutput shows context immediately. Subsequent messages - // are written incrementally below. - void recordSidechainTranscript(messages, taskId).catch(err => - logForDebugging(`bg-session initial transcript write failed: ${err}`), - ) + const finishAbortedTask = (): void => { + restorePreDispatchNotifications() + // chat:killAgents already marks the task notified and emits this event. + // stopTask kills it without doing either for local-agent tasks, so close + // the SDK lifecycle exactly once no matter which phase observed abort. + let alreadyNotified = false + let shouldEvictOutput = false + updateTaskState(taskId, setAppState, task => { + alreadyNotified = task.notified === true + if (task.status === 'running') { + shouldEvictOutput = true + task.unregisterCleanup?.() + const endTime = Date.now() + return { + ...task, + status: 'killed', + endTime, + evictAfter: task.retain ? undefined : endTime + PANEL_GRACE_MS, + abortController: undefined, + unregisterCleanup: undefined, + selectedAgent: undefined, + notified: true, + } + } + return alreadyNotified ? task : { ...task, notified: true } + }) + if (shouldEvictOutput) void evictTaskOutput(taskId) + if (!alreadyNotified) { + emitTaskTerminatedSdk(taskId, 'stopped', { + summary: description, + }) + } + } // Wrap in agent context so skill invocations scope to this task's agentId // (not null). This lets clearInvokedSkills(preservedAgentIds) selectively @@ -373,29 +422,44 @@ export function startBackgroundSession({ void runWithAgentContext(agentContext, async () => { try { + // The task is registered before preparation begins. Ctrl+B can now + // abort the foreground immediately without risking an unowned gap if + // system/user context preparation rejects. + const prepared = await prepare(abortController) + if (prepared === null) { + finishAbortedTask() + return + } + const { messages, queryParams } = prepared + restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent + if (abortSignal.aborted) { + finishAbortedTask() + return + } + + // Persist the pre-backgrounding conversation to the task's isolated + // transcript so TaskOutput shows context immediately. Subsequent + // messages are written incrementally below. + void recordSidechainTranscript(messages, taskId).catch(err => + logForDebugging(`bg-session initial transcript write failed: ${err}`), + ) + const bgMessages: Message[] = [...messages] const recentActivities: ToolActivity[] = [] let toolCount = 0 let tokenCount = 0 let lastRecordedUuid: UUID | null = messages.at(-1)?.uuid ?? null - for await (const event of query({ + for await (const event of queryImpl({ messages: bgMessages, ...queryParams, + onModelRequestStart: () => { + queryParams.onModelRequestStart?.() + if (!abortSignal.aborted) providerRequestStarted = true + }, })) { if (abortSignal.aborted) { - // Aborted mid-stream — completeMainSessionTask won't be reached. - // chat:killAgents path already marked notified + emitted; stopTask path did not. - let alreadyNotified = false - updateTaskState(taskId, setAppState, task => { - alreadyNotified = task.notified === true - return alreadyNotified ? task : { ...task, notified: true } - }) - if (!alreadyNotified) { - emitTaskTerminatedSdk(taskId, 'stopped', { - summary: description, - }) - } + finishAbortedTask() return } @@ -471,8 +535,18 @@ export function startBackgroundSession({ }) } + if (abortSignal.aborted) { + finishAbortedTask() + return + } + restorePreDispatchNotifications() completeMainSessionTask(taskId, true, setAppState) } catch (error) { + if (abortSignal.aborted) { + finishAbortedTask() + return + } + restorePreDispatchNotifications() logError(error) completeMainSessionTask(taskId, false, setAppState) } From afbe89fd5087a3550f02840a1ec3363b781efc72 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 21:25:38 -0700 Subject: [PATCH 14/34] fix(repl): snapshot background handoff transcript --- src/screens/REPL.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 7ef80adac0..f56a8d6a00 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2861,6 +2861,11 @@ export function REPL({ throw backgroundAbortController.signal.reason; } if (!shouldContinue) return null; + + // The foreground is settled, but its QueryGuard will shortly release + // and allow a new prompt. Capture this continuation's transcript before + // any preparation await can observe that later foreground turn. + const settledMessages = [...messagesRef.current]; // Claim foreground-produced notifications immediately after the // settlement decision, before any async context preparation can let // another queue consumer take ownership. @@ -2873,7 +2878,7 @@ export function REPL({ enqueue(notification); } }; - const toolUseContext = getToolUseContext(messagesRef.current, [], backgroundAbortController, mainLoopModel); + const toolUseContext = getToolUseContext(settledMessages, [], backgroundAbortController, mainLoopModel); const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]).catch(error => { restoreNotificationsIfUnsent(); throw error; @@ -2906,14 +2911,14 @@ export function REPL({ // task-notification QueuedCommands (enqueuePendingNotification callers // don't pass uuid), so it would always be undefined. const existingPrompts = new Set(); - for (const m of messagesRef.current) { + for (const m of settledMessages) { if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { existingPrompts.add(m.attachment.prompt); } } const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); return { - messages: [...messagesRef.current, ...uniqueNotifications], + messages: [...settledMessages, ...uniqueNotifications], restoreNotificationsIfUnsent, queryParams: { systemPrompt, From a789f19db80451cf45ee7943567d5785edb758b1 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 21:51:55 -0700 Subject: [PATCH 15/34] fix(tasks): avoid phantom background session task --- src/tasks/LocalMainSessionTask.test.ts | 17 +++++++++++ src/tasks/LocalMainSessionTask.ts | 41 ++++++++++++++++---------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index abada4ad85..02fdbcf6e3 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -29,6 +29,23 @@ afterEach(() => { }) describe('LocalMainSessionTask', () => { + test('does not register a task when foreground settlement needs no continuation', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + + const taskId = startBackgroundSession({ + description: 'settled foreground', + setAppState, + prepare: async () => null, + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(state.tasks[taskId]).toBeUndefined() + }) + test('retains a max-turn terminal attachment in task messages', async () => { let state = getDefaultAppState() const setAppState = (update: (previous: AppState) => AppState): void => { diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 023ae441d1..58a0d172e1 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -99,8 +99,9 @@ export function registerMainSessionTask( setAppState: SetAppState, mainThreadAgentDefinition?: AgentDefinition, existingAbortController?: AbortController, + existingTaskId?: string, ): { taskId: string; abortController: AbortController } { - const taskId = generateMainSessionTaskId() + const taskId = existingTaskId ?? generateMainSessionTaskId() // Link output to an isolated per-task transcript file (same layout as // sub-agents). Do NOT use getTranscriptPath() — that's the main session's @@ -359,12 +360,13 @@ export function startBackgroundSession({ agentDefinition?: AgentDefinition queryImpl?: typeof query }): string { - const { taskId, abortController } = registerMainSessionTask( - description, - setAppState, - agentDefinition, - ) + // Keep a controller while preparation waits for the foreground settlement, + // but do not publish a task until a successor is actually required. A + // settled foreground can complete normally after Ctrl+B is pressed. + const taskId = generateMainSessionTaskId() + const abortController = createAbortController() const abortSignal = abortController.signal + let taskRegistered = false let providerRequestStarted = false let restoreNotificationsIfUnsent: (() => void) | undefined @@ -422,20 +424,25 @@ export function startBackgroundSession({ void runWithAgentContext(agentContext, async () => { try { - // The task is registered before preparation begins. Ctrl+B can now - // abort the foreground immediately without risking an unowned gap if - // system/user context preparation rejects. + // Wait for the foreground settlement before publishing a task. This + // keeps a completion race from producing a phantom background task. const prepared = await prepare(abortController) if (prepared === null) { - finishAbortedTask() return } - const { messages, queryParams } = prepared - restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent if (abortSignal.aborted) { - finishAbortedTask() return } + registerMainSessionTask( + description, + setAppState, + agentDefinition, + abortController, + taskId, + ) + taskRegistered = true + const { messages, queryParams } = prepared + restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent // Persist the pre-backgrounding conversation to the task's isolated // transcript so TaskOutput shows context immediately. Subsequent @@ -543,12 +550,14 @@ export function startBackgroundSession({ completeMainSessionTask(taskId, true, setAppState) } catch (error) { if (abortSignal.aborted) { - finishAbortedTask() + if (taskRegistered) finishAbortedTask() return } - restorePreDispatchNotifications() logError(error) - completeMainSessionTask(taskId, false, setAppState) + if (taskRegistered) { + restorePreDispatchNotifications() + completeMainSessionTask(taskId, false, setAppState) + } } }) From 3a43020f7d81e9708bf0c98d332e0e7a8612c508 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 22:11:56 -0700 Subject: [PATCH 16/34] fix(repl): preserve handoff lifecycle state --- src/query.ts | 4 +++- src/query/requestOnlyMessages.test.ts | 20 ++++++++++++++++++ src/screens/REPL.tsx | 6 ++---- src/tasks/LocalMainSessionTask.test.ts | 28 ++++++++++++++++++++++++++ src/tasks/LocalMainSessionTask.ts | 9 +++++---- src/utils/messageQueueManager.ts | 16 +++++++++++++++ 6 files changed, 74 insertions(+), 9 deletions(-) diff --git a/src/query.ts b/src/query.ts index 0ecd7da4d3..c0e588c3ab 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1553,6 +1553,9 @@ async function* queryLoop( return false } params.onModelRequestStart?.() + // The matching end callback belongs to the lifecycle start, + // even when that callback aborts before provider dispatch. + providerRequestStarted = true // The lifecycle callback is synchronous but may itself abort. if (toolUseContext.abortController.signal.aborted) { providerDispatchRejected = true @@ -1567,7 +1570,6 @@ async function* queryLoop( params.turnBudget.turnsStarted = turnCount reservedTurnCount = turnCount } - providerRequestStarted = true return true }, // Explicit /effort selection wins. When it is unset, carry the diff --git a/src/query/requestOnlyMessages.test.ts b/src/query/requestOnlyMessages.test.ts index 22731269c8..77a64e6ffc 100644 --- a/src/query/requestOnlyMessages.test.ts +++ b/src/query/requestOnlyMessages.test.ts @@ -309,6 +309,26 @@ test('scopes model-request lifecycle callbacks to provider dispatch', async () = expect(events).toEqual(['start', 'end']) }) +test('closes the model-request lifecycle when its start callback aborts', async () => { + const events: string[] = [] + const params = baseParams( + async function* ({ options }) { + if (options.onProviderRequestStart?.() === false) return + yield createAssistantMessage({ content: 'must not dispatch' }) + }, + async () => ({ wasCompacted: false }), + ) + params.onModelRequestStart = () => { + events.push('start') + params.toolUseContext.abortController.abort('background') + } + params.onModelRequestEnd = () => events.push('end') + + await collect(params) + + expect(events).toEqual(['start', 'end']) +}) + for (const preserveCorrection of [false, true]) { test(`reapplies request-only context after ${ preserveCorrection ? 'suffix-preserving' : 'full' diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index f56a8d6a00..cf95355f9a 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -210,7 +210,7 @@ import { useIDEIntegration } from '../hooks/useIDEIntegration.js'; import exit from '../commands/exit/index.js'; import { ExitFlow } from '../components/ExitFlow.js'; import { getCurrentWorktreeSession } from '../utils/worktree.js'; -import { popAllEditable, enqueue, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter } from '../utils/messageQueueManager.js'; +import { popAllEditable, enqueue, prepend, type SetAppState, getCommandQueue, getCommandQueueLength, removeByFilter } from '../utils/messageQueueManager.js'; import { useCommandQueue } from '../hooks/useCommandQueue.js'; import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'; import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'; @@ -2874,9 +2874,7 @@ export function REPL({ const restoreNotificationsIfUnsent = () => { if (!notificationOwnershipActive) return; notificationOwnershipActive = false; - for (const notification of pendingNotifications) { - enqueue(notification); - } + prepend(pendingNotifications); }; const toolUseContext = getToolUseContext(settledMessages, [], backgroundAbortController, mainLoopModel); const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]).catch(error => { diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 02fdbcf6e3..4d927e39db 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -46,6 +46,34 @@ describe('LocalMainSessionTask', () => { expect(state.tasks[taskId]).toBeUndefined() }) + test('restores claimed notifications when preparation returns after abort', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + let restoreCalls = 0 + + const taskId = startBackgroundSession({ + description: 'aborted preparation', + setAppState, + prepare: async abortController => { + abortController.abort('stopped') + return { + messages: [], + restoreNotificationsIfUnsent: () => { + restoreCalls++ + }, + queryParams: {} as Omit, + } + }, + }) + + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(restoreCalls).toBe(1) + expect(state.tasks[taskId]).toBeUndefined() + }) + test('retains a max-turn terminal attachment in task messages', async () => { let state = getDefaultAppState() const setAppState = (update: (previous: AppState) => AppState): void => { diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 58a0d172e1..afaea83e24 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -336,9 +336,9 @@ type ToolActivity = { /** * Start a fresh background session with the given messages. * - * Registers the background task synchronously, then prepares and starts an - * independent query() call. Registration precedes preparation so the caller - * can safely abort the foreground once this function returns. + * Prepares the continuation first, then registers the background task and + * starts an independent query() call. A foreground that settles without a + * successor therefore never publishes a task. */ export function startBackgroundSession({ prepare, @@ -430,7 +430,9 @@ export function startBackgroundSession({ if (prepared === null) { return } + restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent if (abortSignal.aborted) { + restorePreDispatchNotifications() return } registerMainSessionTask( @@ -442,7 +444,6 @@ export function startBackgroundSession({ ) taskRegistered = true const { messages, queryParams } = prepared - restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent // Persist the pre-backgrounding conversation to the task's isolated // transcript so TaskOutput shows context immediately. Subsequent diff --git a/src/utils/messageQueueManager.ts b/src/utils/messageQueueManager.ts index 1db54b53d8..587901af99 100644 --- a/src/utils/messageQueueManager.ts +++ b/src/utils/messageQueueManager.ts @@ -133,6 +133,22 @@ export function enqueue(command: QueuedCommand): void { ) } +/** + * Restore commands ahead of commands added after they were claimed, without + * changing their priority. Commands in the supplied array retain FIFO order. + */ +export function prepend(commands: readonly QueuedCommand[]): void { + if (commands.length === 0) return + commandQueue.unshift(...commands) + notifySubscribers() + for (const command of commands) { + logOperation( + 'enqueue', + typeof command.value === 'string' ? command.value : undefined, + ) + } +} + /** * Add a task notification to the queue. * Convenience wrapper that defaults priority to 'later' so user input From 09e794f80c7adcce08b90485210c2694875c8532 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 22:48:23 -0700 Subject: [PATCH 17/34] fix(repl): own pending background handoffs --- src/screens/REPL.tsx | 38 ++++++++-- src/tasks/LocalMainSessionTask.test.ts | 101 ++++++++++++++++++++++++- src/tasks/LocalMainSessionTask.ts | 20 ++++- 3 files changed, 148 insertions(+), 11 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index cf95355f9a..75e16fa301 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2851,7 +2851,7 @@ export function REPL({ // cap while the foreground query is winding down. const backgroundHandoff = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); if (!backgroundHandoff) return; - startBackgroundSession({ + const backgroundSession = startBackgroundSession({ prepare: async backgroundAbortController => { // The foreground owns transcript completion. Wait until its abort path // has appended terminal tool results before snapshotting continuation @@ -2876,6 +2876,7 @@ export function REPL({ notificationOwnershipActive = false; prepend(pendingNotifications); }; + try { const toolUseContext = getToolUseContext(settledMessages, [], backgroundAbortController, mainLoopModel); const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([getSystemPrompt(toolUseContext.options.tools, mainLoopModel, Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), toolUseContext.options.mcpClients), getUserContext(), getSystemContext()]).catch(error => { restoreNotificationsIfUnsent(); @@ -2933,15 +2934,38 @@ export function REPL({ } } }; + } catch (error) { + restoreNotificationsIfUnsent(); + throw error; + } }, description: terminalTitle, setAppState, - agentDefinition: mainThreadAgentDefinition + agentDefinition: mainThreadAgentDefinition, + onPreparationError: () => { + addNotification({ + key: 'background-session-start-failed', + text: 'Could not start the background session. The current request was cancelled.', + priority: 'high', + }); + }, + onRegistered: controller => { + setAbortController(current => + current === controller ? null : current, + ); + }, + onSettled: controller => { + setAbortController(current => + current === controller ? null : current, + ); + }, }); - // The successor task now exists and owns preparation failures, so the - // foreground can be aborted without leaving an unowned continuation gap. + // The task is intentionally published only after the foreground settles, + // but its controller must be reachable during preparation so Escape can + // cancel the handoff before it dispatches a provider request. + setAbortController(backgroundSession.abortController); abortController?.abort('background'); - }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel]); + }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, setAbortController, addNotification]); const { handleBackgroundSession } = useSessionBackgrounding({ @@ -3450,7 +3474,9 @@ export function REPL({ // reads false at the idle prompt. Without this, the stale non-aborted // controller makes ctrl+c fire onCancel() (aborting nothing) instead of // propagating to the double-press exit flow. - setAbortController(null); + setAbortController(current => + current === abortController ? null : current, + ); } else { const guardCompletedContext = queryGuard.lastContext; if ((guardCompletedContext?.terminalReason === 'query-timeout' || guardCompletedContext?.terminalReason === 'hard-max-query-timeout') && guardCompletedContext.queryGeneration === thisGeneration) { diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 4d927e39db..f8eee14c9e 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -35,7 +35,7 @@ describe('LocalMainSessionTask', () => { state = update(state) } - const taskId = startBackgroundSession({ + const { taskId } = startBackgroundSession({ description: 'settled foreground', setAppState, prepare: async () => null, @@ -53,7 +53,7 @@ describe('LocalMainSessionTask', () => { } let restoreCalls = 0 - const taskId = startBackgroundSession({ + const { taskId } = startBackgroundSession({ description: 'aborted preparation', setAppState, prepare: async abortController => { @@ -89,7 +89,7 @@ describe('LocalMainSessionTask', () => { return { reason: 'max_turns', turnCount: 2 } } - const taskId = startBackgroundSession({ + const { taskId } = startBackgroundSession({ description: 'cap retention test', setAppState, queryImpl: queryImpl as typeof import('../query.js').query, @@ -109,4 +109,99 @@ describe('LocalMainSessionTask', () => { if (!isMainSessionTask(task)) throw new Error('expected main-session task') expect(task.messages).toEqual([cap]) }) + + test('reports a preparation failure before task registration', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + const failure = new Error('context failed') + const errors: unknown[] = [] + + const { taskId } = startBackgroundSession({ + description: 'failing preparation', + setAppState, + prepare: async () => { + throw failure + }, + onPreparationError: error => errors.push(error), + }) + + await waitFor(() => errors.length === 1, 'preparation failure callback') + + expect(errors).toEqual([failure]) + expect(state.tasks[taskId]).toBeUndefined() + }) + + test('settles the pending controller after preparation completes', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + const settledControllers: AbortController[] = [] + + const { taskId, abortController } = startBackgroundSession({ + description: 'settled preparation', + setAppState, + prepare: async () => null, + onSettled: controller => settledControllers.push(controller), + }) + + await waitFor(() => settledControllers.length === 1, 'handoff settlement') + + expect(settledControllers).toEqual([abortController]) + expect(state.tasks[taskId]).toBeUndefined() + }) + + test('reports when a pending handoff is registered as a task', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + const registeredControllers: AbortController[] = [] + + const { taskId, abortController } = startBackgroundSession({ + description: 'registered handoff', + setAppState, + prepare: async () => ({ + messages: [], + queryParams: {} as Omit, + }), + onRegistered: controller => registeredControllers.push(controller), + queryImpl: (async function* (): AsyncGenerator { + return { reason: 'completed' } + }) as typeof import('../query.js').query, + }) + + await waitFor(() => state.tasks[taskId]?.status === 'completed', 'task completion') + + expect(registeredControllers).toEqual([abortController]) + }) + + test('exposes a controller that cancels preparation before registration', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + let releasePreparation!: () => void + const preparation = new Promise(resolve => { + releasePreparation = resolve + }) + + const { taskId, abortController } = startBackgroundSession({ + description: 'cancellable preparation', + setAppState, + prepare: async controller => { + await preparation + if (controller.signal.aborted) return null + throw new Error('preparation should have been cancelled') + }, + }) + + abortController.abort('user-cancel') + releasePreparation() + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(state.tasks[taskId]).toBeUndefined() + }) }) diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index afaea83e24..85569bd918 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -333,6 +333,11 @@ type ToolActivity = { input: Record } +export type BackgroundSessionHandle = { + taskId: string + abortController: AbortController +} + /** * Start a fresh background session with the given messages. * @@ -346,6 +351,9 @@ export function startBackgroundSession({ setAppState, agentDefinition, queryImpl = query, + onPreparationError, + onRegistered, + onSettled, }: { prepare: (abortController: AbortController) => Promise< | { @@ -359,7 +367,10 @@ export function startBackgroundSession({ setAppState: SetAppState agentDefinition?: AgentDefinition queryImpl?: typeof query -}): string { + onPreparationError?: (error: unknown) => void + onRegistered?: (abortController: AbortController) => void + onSettled?: (abortController: AbortController) => void +}): BackgroundSessionHandle { // Keep a controller while preparation waits for the foreground settlement, // but do not publish a task until a successor is actually required. A // settled foreground can complete normally after Ctrl+B is pressed. @@ -443,6 +454,7 @@ export function startBackgroundSession({ taskId, ) taskRegistered = true + onRegistered?.(abortController) const { messages, queryParams } = prepared // Persist the pre-backgrounding conversation to the task's isolated @@ -558,9 +570,13 @@ export function startBackgroundSession({ if (taskRegistered) { restorePreDispatchNotifications() completeMainSessionTask(taskId, false, setAppState) + } else { + onPreparationError?.(error) } + } finally { + onSettled?.(abortController) } }) - return taskId + return { taskId, abortController } } From 14b1c8b0b0c45ae839e87a0bf17789256e9689cc Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:21:19 -0700 Subject: [PATCH 18/34] test(tasks): isolate background session task storage --- src/tasks/LocalMainSessionTask.test.ts | 65 +++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index f8eee14c9e..e8548f4799 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -1,11 +1,30 @@ -import { afterEach, describe, expect, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import { mkdtemp, mkdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { getOriginalCwd, setOriginalCwd } from '../bootstrap/state.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' import type { QueryParams } from '../query.js' import type { Terminal } from '../query/transitions.js' import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' import type { Message } from '../types/message.js' import { createAttachmentMessage } from '../utils/attachments.js' +import { + getClaudeConfigHomeDir, + getClaudeConfigHomeDirOverrideForTesting, + setClaudeConfigHomeDirForTesting, +} from '../utils/envUtils.js' import { dequeueAll } from '../utils/messageQueueManager.js' +import { getClaudeTempDir } from '../utils/permissions/filesystem.js' +import { getProjectDir, resetProjectForTesting } from '../utils/sessionStorage.js' +import { + _clearOutputsForTest, + _resetTaskOutputDirForTest, +} from '../utils/task/diskOutput.js' import { isMainSessionTask, startBackgroundSession, @@ -24,8 +43,50 @@ async function waitFor( } } -afterEach(() => { +let originalCwd: string +let originalConfigDir: string | undefined +let originalClaudeTmpDir: string | undefined +let testRoot: string + +beforeEach(async () => { + await acquireSharedMutationLock('tasks/LocalMainSessionTask.test.ts') + originalCwd = getOriginalCwd() + originalConfigDir = getClaudeConfigHomeDirOverrideForTesting() + originalClaudeTmpDir = process.env.CLAUDE_CODE_TMPDIR + testRoot = await mkdtemp(join(tmpdir(), 'openclaude-main-session-task-')) + const projectDir = join(testRoot, 'project') + await mkdir(projectDir) + + setClaudeConfigHomeDirForTesting(join(testRoot, 'config')) + getClaudeConfigHomeDir.cache?.clear?.() + getProjectDir.cache?.clear?.() + setOriginalCwd(projectDir) + process.env.CLAUDE_CODE_TMPDIR = join(testRoot, 'tmp') + getClaudeTempDir.cache?.clear?.() + _resetTaskOutputDirForTest() + resetProjectForTesting() +}) + +afterEach(async () => { dequeueAll() + try { + await _clearOutputsForTest() + resetProjectForTesting() + _resetTaskOutputDirForTest() + setClaudeConfigHomeDirForTesting(originalConfigDir) + getClaudeConfigHomeDir.cache?.clear?.() + getProjectDir.cache?.clear?.() + setOriginalCwd(originalCwd) + if (originalClaudeTmpDir === undefined) { + delete process.env.CLAUDE_CODE_TMPDIR + } else { + process.env.CLAUDE_CODE_TMPDIR = originalClaudeTmpDir + } + getClaudeTempDir.cache?.clear?.() + await rm(testRoot, { recursive: true, force: true }) + } finally { + releaseSharedMutationLock() + } }) describe('LocalMainSessionTask', () => { From ffe9c02b8ac570e4ff85d2dad7c251008f6960ec Mon Sep 17 00:00:00 2001 From: jatmn Date: Sat, 1 Aug 2026 23:24:43 -0700 Subject: [PATCH 19/34] fix(repl): refresh background task title and test cleanup --- src/screens/REPL.tsx | 2 +- src/tasks/LocalMainSessionTask.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 75e16fa301..6181dcc41c 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2965,7 +2965,7 @@ export function REPL({ // cancel the handoff before it dispatches a provider request. setAbortController(backgroundSession.abortController); abortController?.abort('background'); - }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, setAbortController, addNotification]); + }, [abortController, mainLoopModel, toolPermissionContext, mainThreadAgentDefinition, getToolUseContext, customSystemPrompt, appendSystemPrompt, canUseTool, setAppState, getAutoCompactTrackingForSession, setAutoCompactTrackingForSession, fallbackModel, setAbortController, addNotification, terminalTitle]); const { handleBackgroundSession } = useSessionBackgrounding({ diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index e8548f4799..ede19534f5 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -20,7 +20,11 @@ import { } from '../utils/envUtils.js' import { dequeueAll } from '../utils/messageQueueManager.js' import { getClaudeTempDir } from '../utils/permissions/filesystem.js' -import { getProjectDir, resetProjectForTesting } from '../utils/sessionStorage.js' +import { + flushSessionStorage, + getProjectDir, + resetProjectForTesting, +} from '../utils/sessionStorage.js' import { _clearOutputsForTest, _resetTaskOutputDirForTest, @@ -71,6 +75,7 @@ afterEach(async () => { dequeueAll() try { await _clearOutputsForTest() + await flushSessionStorage() resetProjectForTesting() _resetTaskOutputDirForTest() setClaudeConfigHomeDirForTesting(originalConfigDir) From b1f5930714223125f9ea9835000a4c518b31c917 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 2 Aug 2026 11:12:16 -0700 Subject: [PATCH 20/34] test(repl): cover max-turn CLI dispatch paths --- src/main.tsx | 8 ++---- src/query/requestOnlyMessages.test.ts | 3 +++ src/screens/replMaxTurnsProp.test.ts | 15 ++++------- src/utils/messageQueueManager.test.ts | 36 +++++++++++++++++++++++++++ src/utils/replMaxTurns.ts | 12 +++++++++ 5 files changed, 58 insertions(+), 16 deletions(-) create mode 100644 src/utils/messageQueueManager.test.ts diff --git a/src/main.tsx b/src/main.tsx index 57857573df..45ec54b514 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -123,7 +123,7 @@ import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel } from './utils/model/model.js'; import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; -import { MAX_TURNS_CLI_DESCRIPTION, parseMaxTurnsCli } from './utils/replMaxTurns.js'; +import { MAX_TURNS_CLI_DESCRIPTION, parseMaxTurnsCommanderArgument } from './utils/replMaxTurns.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, initializeToolPermissionContext, initialPermissionModeFromCLI, isDefaultPermissionModeAuto, parseToolListFromCLI, stripDangerousPermissionsForAutoMode, verifyAutoModeGateAccess } from './utils/permissions/permissionSetup.js'; import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; @@ -947,11 +947,7 @@ async function run(): Promise { throw new InvalidArgumentError(errorMessage(error)); } })).option('--bare', 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', () => true).addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()).addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()).addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()).addOption(new Option('--output-format ', 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)').choices(['text', 'json', 'stream-json'])).addOption(new Option('--json-schema ', 'JSON Schema for structured output validation. ' + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}').argParser(String)).option('--include-hook-events', 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', () => true).option('--include-partial-messages', 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', () => true).addOption(new Option('--input-format ', 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)').choices(['text', 'stream-json'])).option('--mcp-debug', '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', () => true).option('--dangerously-skip-permissions', 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', () => true).option('--allow-dangerously-skip-permissions', 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', () => true).addOption(new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled').choices(['enabled', 'adaptive', 'disabled']).hideHelp()).addOption(new Option('--max-thinking-tokens ', '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)').argParser(Number).hideHelp()).addOption(new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser(value => { - try { - return parseMaxTurnsCli(value); - } catch (error) { - throw new InvalidArgumentError(errorMessage(error)); - } + return parseMaxTurnsCommanderArgument(value); })).addOption(new Option('--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)').argParser(value => { const amount = Number(value); if (isNaN(amount) || amount <= 0) { diff --git a/src/query/requestOnlyMessages.test.ts b/src/query/requestOnlyMessages.test.ts index 77a64e6ffc..268c9a8a57 100644 --- a/src/query/requestOnlyMessages.test.ts +++ b/src/query/requestOnlyMessages.test.ts @@ -311,9 +311,11 @@ test('scopes model-request lifecycle callbacks to provider dispatch', async () = test('closes the model-request lifecycle when its start callback aborts', async () => { const events: string[] = [] + let providerDispatched = false const params = baseParams( async function* ({ options }) { if (options.onProviderRequestStart?.() === false) return + providerDispatched = true yield createAssistantMessage({ content: 'must not dispatch' }) }, async () => ({ wasCompacted: false }), @@ -326,6 +328,7 @@ test('closes the model-request lifecycle when its start callback aborts', async await collect(params) + expect(providerDispatched).toBe(false) expect(events).toEqual(['start', 'end']) }) diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index b692a30de0..784b127b70 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -25,6 +25,7 @@ import { MAX_TURNS_CLI_DESCRIPTION, MAX_TURNS_UNLIMITED_WARNING, normalizeReplMaxTurns, + parseMaxTurnsCommanderArgument, parseMaxTurnsCli, REPL_MAX_TURNS_OPTIONS, resolveReplMaxTurns, @@ -82,13 +83,7 @@ function createMaxTurnsCliProgram(): CommanderCommand { .addOption( new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION).argParser( value => { - try { - return parseMaxTurnsCli(value) - } catch (error) { - throw new InvalidArgumentError( - error instanceof Error ? error.message : String(error), - ) - } + return parseMaxTurnsCommanderArgument(value) }, ), ) @@ -339,16 +334,16 @@ describe('interactive REPL max-turn cap', () => { }) }) - test('main imports the shared --max-turns description and wires sessionConfig', () => { + test('main uses the production Commander parser and wires sessionConfig', () => { // main.tsx is hard to boot in unit tests; assert the import so the // shared constant cannot be referenced without being bundled, plus the // local interactive sessionConfig handoff (same pattern as fallbackModel). const source = readSourceUp('main.tsx') expect(source).toMatch( - /import\s*\{[^}]*\bMAX_TURNS_CLI_DESCRIPTION\b[^}]*\}\s*from\s*'\.\/utils\/replMaxTurns\.js'/, + /import\s*\{[^}]*\bparseMaxTurnsCommanderArgument\b[^}]*\}\s*from\s*'\.\/utils\/replMaxTurns\.js'/, ) expect(source).toContain( - "new Option('--max-turns ', MAX_TURNS_CLI_DESCRIPTION)", + 'return parseMaxTurnsCommanderArgument(value);', ) const body = objectBody(source, /const sessionConfig = \{/) expect(body).toContain('maxTurns: options.maxTurns') diff --git a/src/utils/messageQueueManager.test.ts b/src/utils/messageQueueManager.test.ts new file mode 100644 index 0000000000..ab32eed5e8 --- /dev/null +++ b/src/utils/messageQueueManager.test.ts @@ -0,0 +1,36 @@ +import { afterEach, expect, test } from 'bun:test' +import { + dequeueAll, + enqueue, + getCommandQueue, + prepend, + resetCommandQueue, + subscribeToCommandQueue, +} from './messageQueueManager.js' + +afterEach(() => resetCommandQueue()) + +test('prepend restores commands ahead of later enqueues in FIFO order', () => { + const notifications: number[] = [] + const unsubscribe = subscribeToCommandQueue(() => notifications.push(1)) + try { + const restored = [ + { value: 'first restored', mode: 'prompt' as const, priority: 'later' as const }, + { value: 'second restored', mode: 'prompt' as const, priority: 'now' as const }, + ] + prepend(restored) + enqueue({ value: 'later enqueue', mode: 'prompt' }) + + expect(getCommandQueue()).toEqual([ + ...restored, + { value: 'later enqueue', mode: 'prompt', priority: 'next' }, + ]) + expect(notifications).toHaveLength(2) + expect(dequeueAll()).toEqual([ + ...restored, + { value: 'later enqueue', mode: 'prompt', priority: 'next' }, + ]) + } finally { + unsubscribe() + } +}) diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index 746e907b9e..c9af8da466 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -1,5 +1,6 @@ import { getGlobalConfig } from './config.js' import { logForDebugging } from './debug.js' +import { InvalidArgumentError } from '@commander-js/extra-typings' export const DEFAULT_REPL_MAX_TURNS = 50 export const MAX_TURNS_UNLIMITED_WARNING = @@ -23,6 +24,17 @@ export function parseMaxTurnsCli(value: string): number { return parsed } +/** Commander-compatible parser used by the production --max-turns option. */ +export function parseMaxTurnsCommanderArgument(value: string): number { + try { + return parseMaxTurnsCli(value) + } catch (error) { + throw new InvalidArgumentError( + error instanceof Error ? error.message : String(error), + ) + } +} + /** * Prefer OPENCLAUDE_MAX_TURNS; honor legacy CLAUDE_CODE_MAX_TURNS only when * the new variable is unset/empty. Invalid, zero, negative, non-integer, or From 94d714b53f292bf8185e33c4d93e7fdbe4eb1e89 Mon Sep 17 00:00:00 2001 From: jatmn Date: Sun, 2 Aug 2026 11:46:20 -0700 Subject: [PATCH 21/34] test(queue): cover prepend notification and priority --- src/utils/messageQueueManager.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/utils/messageQueueManager.test.ts b/src/utils/messageQueueManager.test.ts index ab32eed5e8..bfe893e243 100644 --- a/src/utils/messageQueueManager.test.ts +++ b/src/utils/messageQueueManager.test.ts @@ -1,5 +1,6 @@ import { afterEach, expect, test } from 'bun:test' import { + dequeue, dequeueAll, enqueue, getCommandQueue, @@ -17,8 +18,10 @@ test('prepend restores commands ahead of later enqueues in FIFO order', () => { const restored = [ { value: 'first restored', mode: 'prompt' as const, priority: 'later' as const }, { value: 'second restored', mode: 'prompt' as const, priority: 'now' as const }, + { value: 'third restored', mode: 'prompt' as const, priority: 'now' as const }, ] prepend(restored) + expect(notifications).toHaveLength(1) enqueue({ value: 'later enqueue', mode: 'prompt' }) expect(getCommandQueue()).toEqual([ @@ -26,8 +29,10 @@ test('prepend restores commands ahead of later enqueues in FIFO order', () => { { value: 'later enqueue', mode: 'prompt', priority: 'next' }, ]) expect(notifications).toHaveLength(2) + expect(dequeue()).toMatchObject(restored[1]!) + expect(dequeue()).toMatchObject(restored[2]!) expect(dequeueAll()).toEqual([ - ...restored, + restored[0], { value: 'later enqueue', mode: 'prompt', priority: 'next' }, ]) } finally { From 4fa9c8d290bf07cdd1b2d7bf8c995ee23dcb55d3 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 17:36:54 -0700 Subject: [PATCH 22/34] fix(repl): skip background handoff after foreground query throws Rebased onto main and gate Ctrl+B continuation on !didThrow so a faulted foreground turn cannot start a background session from partial state. --- src/screens/REPL.tsx | 7 ++++++- src/screens/replMaxTurnsProp.test.ts | 7 +++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 6181dcc41c..d6f82697a9 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -3358,7 +3358,12 @@ export function REPL({ // The ref is only an ownership marker for the currently foregrounded // prompt. A background handoff already captured the budget object, so // clear the ref on every terminal path without disturbing a newer query. - const shouldContinueBackground = abortController.signal.reason === 'background' && (queryTerminal === undefined || queryTerminal.reason === 'aborted_streaming' || queryTerminal.reason === 'aborted_tools'); + const shouldContinueBackground = + !didThrow && + abortController.signal.reason === 'background' && + (queryTerminal === undefined || + queryTerminal.reason === 'aborted_streaming' || + queryTerminal.reason === 'aborted_tools'); releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); // A provider response can hand off to tools before the assistant turn // finishes. Keep correction ownership through that work (and retries), diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 784b127b70..d1d4f6c222 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -271,6 +271,13 @@ describe('interactive REPL max-turn cap', () => { await expect(wait).resolves.toBeNull() }) + test('does not continue a background handoff after the foreground query throws', () => { + const source = readScreen('REPL.tsx') + const onQueryBody = objectBody(source, /const onQuery = useCallback/) + expect(onQueryBody).toContain('!didThrow') + expect(onQueryBody).toContain('shouldContinueBackground') + }) + test('Config panel exposes Max turns (interactive)', () => { const source = readSourceUp(join('components', 'Settings', 'Config.tsx')) expect(source).toContain("id: 'replMaxTurns'") From 2978cb1a4a07cc6ab0ad38912438e2434174a274 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 18:10:42 -0700 Subject: [PATCH 23/34] fix(repl): address PR review findings on notifications and handoff Dedupe background task notifications by embedded task id, gate background continuation on preflight veto, scope queue removal to main-thread notifications, and forward maxTurns through all launchRepl entry points. --- src/main.tsx | 12 +- src/screens/REPL.tsx | 45 ++++--- src/screens/replMaxTurns.ts | 46 ++++++++ src/screens/replMaxTurnsProp.test.ts | 129 +++++++++++++++------ src/utils/taskNotificationIdentity.test.ts | 40 +++++++ src/utils/taskNotificationIdentity.ts | 22 ++++ 6 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 src/utils/taskNotificationIdentity.test.ts create mode 100644 src/utils/taskNotificationIdentity.ts diff --git a/src/main.tsx b/src/main.tsx index 45ec54b514..be9171f38c 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3154,7 +3154,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, directConnectConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { @@ -3220,7 +3221,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, sshSession, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (feature('KAIROS') && _pendingAssistantChat && (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover)) { @@ -3316,7 +3318,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (options.resume || options.fromPr || teleport || remote !== null) { @@ -3465,7 +3468,8 @@ async function run(): Promise { mainThreadAgentDefinition, disableSlashCommands, remoteSessionConfig, - thinkingConfig + thinkingConfig, + maxTurns: options.maxTurns, }, renderAndRun); return; } else if (teleport) { diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index d6f82697a9..81c64762e9 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -39,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { claimBackgroundTurnBudget, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; +import { claimBackgroundTurnBudget, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, shouldShowReplMaxTurnsUnlimitedWarning, shouldContinueBackgroundAfterForegroundQuery, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -291,6 +291,7 @@ import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type M import { setClipboard } from '../ink/termio/osc.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; +import { getTaskNotificationDedupKey } from '../utils/taskNotificationIdentity.js'; // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would @@ -667,9 +668,17 @@ export function REPL({ const backgroundHandoffStartedRef = useRef(false); useEffect(() => { - const warning = getReplMaxTurnsWarning(maxTurnsProp); - if (warning && !isRemoteSession && !directConnectConfig && !sshSession) { - process.stderr.write(chalk.yellow(`${warning}\n`)); + if ( + shouldShowReplMaxTurnsUnlimitedWarning(maxTurnsProp, { + isRemoteSession, + directConnectConfig, + sshSession, + }) + ) { + const warning = getReplMaxTurnsWarning(maxTurnsProp) + if (warning) { + process.stderr.write(chalk.yellow(`${warning}\n`)) + } } }, [maxTurnsProp, isRemoteSession, directConnectConfig, sshSession]); @@ -2869,7 +2878,10 @@ export function REPL({ // Claim foreground-produced notifications immediately after the // settlement decision, before any async context preparation can let // another queue consumer take ownership. - const pendingNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); + const pendingNotifications = removeByFilter( + cmd => + cmd.mode === 'task-notification' && cmd.agentId === undefined, + ); let notificationOwnershipActive = true; const restoreNotificationsIfUnsent = () => { if (!notificationOwnershipActive) return; @@ -2906,16 +2918,16 @@ export function REPL({ // Deduplicate: if the query loop already yielded a notification into // messagesRef before we removed it from the queue, skip duplicates. - // We use prompt text for dedup because source_uuid is not set on - // task-notification QueuedCommands (enqueuePendingNotification callers - // don't pass uuid), so it would always be undefined. - const existingPrompts = new Set(); + // Use the embedded task id so distinct tasks with identical summary + // text are not collapsed. + const existingNotificationKeys = new Set(); for (const m of settledMessages) { if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { - existingPrompts.add(m.attachment.prompt); + existingNotificationKeys.add(getTaskNotificationDedupKey(m.attachment.prompt)); } } - const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt))); + const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingNotificationKeys.has(getTaskNotificationDedupKey(m.attachment.prompt)))); + notificationOwnershipActive = false; return { messages: [...settledMessages, ...uniqueNotifications], restoreNotificationsIfUnsent, @@ -3359,11 +3371,12 @@ export function REPL({ // prompt. A background handoff already captured the budget object, so // clear the ref on every terminal path without disturbing a newer query. const shouldContinueBackground = - !didThrow && - abortController.signal.reason === 'background' && - (queryTerminal === undefined || - queryTerminal.reason === 'aborted_streaming' || - queryTerminal.reason === 'aborted_tools'); + shouldContinueBackgroundAfterForegroundQuery({ + didThrow, + preflightVetoed, + abortReason: abortController.signal.reason, + queryTerminal, + }); releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); // A provider response can hand off to tools before the assistant turn // finishes. Keep correction ownership through that work (and retries), diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 0701190b41..91b80418a8 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -2,6 +2,8 @@ import { createQueryTurnBudget, type QueryTurnBudget, } from '../query.js' +import type { Terminal as QueryTerminal } from '../query/transitions.js' +import { getReplMaxTurnsWarning } from '../utils/replMaxTurns.js' export { DEFAULT_REPL_MAX_TURNS, @@ -12,6 +14,50 @@ export { resolveReplMaxTurns, } from '../utils/replMaxTurns.js' +export function isLocalInteractiveMaxTurnsSession(session: { + isRemoteSession: boolean + directConnectConfig: unknown + sshSession: unknown +}): boolean { + return ( + !session.isRemoteSession && + !session.directConnectConfig && + !session.sshSession + ) +} + +export function shouldShowReplMaxTurnsUnlimitedWarning( + maxTurns: number | undefined, + session: { + isRemoteSession: boolean + directConnectConfig: unknown + sshSession: unknown + }, +): boolean { + const warning = getReplMaxTurnsWarning(maxTurns) + return warning !== undefined && isLocalInteractiveMaxTurnsSession(session) +} + +export function shouldContinueBackgroundAfterForegroundQuery({ + didThrow, + preflightVetoed, + abortReason, + queryTerminal, +}: { + didThrow: boolean + preflightVetoed: boolean + abortReason: unknown + queryTerminal: QueryTerminal | undefined +}): boolean { + return ( + !didThrow && + !preflightVetoed && + abortReason === 'background' && + (queryTerminal?.reason === 'aborted_streaming' || + queryTerminal?.reason === 'aborted_tools') + ) +} + type MutableRef = { current: T } export type ForegroundTurnBudgetHandoff = { diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index d1d4f6c222..674bfd2e10 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -9,7 +9,10 @@ import { import { claimBackgroundTurnBudget, createForegroundTurnBudgetHandoff, + isLocalInteractiveMaxTurnsSession, releaseForegroundTurnBudget, + shouldContinueBackgroundAfterForegroundQuery, + shouldShowReplMaxTurnsUnlimitedWarning, waitForForegroundTurnBudgetSettlement, } from './replMaxTurns.js' import { @@ -47,21 +50,6 @@ function readSourceUp(name: string): string { return readFileSync(join(screenDir, '..', name), 'utf8') } -function objectBody(source: string, marker: RegExp): string { - const match = source.match(marker) - expect(match).not.toBeNull() - const start = match!.index! + match![0].length - 1 - let depth = 0 - for (let index = start; index < source.length; index++) { - if (source[index] === '{') depth++ - if (source[index] === '}') { - depth-- - if (depth === 0) return source.slice(start, index + 1) - } - } - throw new Error(`Unclosed object after ${marker}`) -} - function clearTurnEnv(): void { for (const key of ENV_KEYS) { delete process.env[key] @@ -207,10 +195,46 @@ describe('interactive REPL max-turn cap', () => { }) test('emits the unlimited warning only from a local REPL', () => { - const repl = readScreen('REPL.tsx') - const main = readSourceUp('main.tsx') - expect(repl).toContain('!isRemoteSession && !directConnectConfig && !sshSession') - expect(main).not.toContain('getReplMaxTurnsWarning') + expect( + shouldShowReplMaxTurnsUnlimitedWarning(0, { + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: undefined, + }), + ).toBe(true) + expect( + shouldShowReplMaxTurnsUnlimitedWarning(0, { + isRemoteSession: true, + directConnectConfig: undefined, + sshSession: undefined, + }), + ).toBe(false) + expect( + shouldShowReplMaxTurnsUnlimitedWarning(0, { + isRemoteSession: false, + directConnectConfig: {}, + sshSession: undefined, + }), + ).toBe(false) + expect( + shouldShowReplMaxTurnsUnlimitedWarning(0, { + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: {}, + }), + ).toBe(false) + expect( + shouldShowReplMaxTurnsUnlimitedWarning(50, { + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: undefined, + }), + ).toBe(false) + expect(isLocalInteractiveMaxTurnsSession({ + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: undefined, + })).toBe(true) }) test('normalizeReplMaxTurns matches /config picker persistence', () => { @@ -272,10 +296,54 @@ describe('interactive REPL max-turn cap', () => { }) test('does not continue a background handoff after the foreground query throws', () => { - const source = readScreen('REPL.tsx') - const onQueryBody = objectBody(source, /const onQuery = useCallback/) - expect(onQueryBody).toContain('!didThrow') - expect(onQueryBody).toContain('shouldContinueBackground') + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: true, + preflightVetoed: false, + abortReason: 'background', + queryTerminal: { reason: 'aborted_streaming' }, + }), + ).toBe(false) + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: false, + preflightVetoed: true, + abortReason: 'background', + queryTerminal: undefined, + }), + ).toBe(false) + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: false, + preflightVetoed: false, + abortReason: 'background', + queryTerminal: { reason: 'aborted_streaming' }, + }), + ).toBe(true) + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: false, + preflightVetoed: false, + abortReason: 'background', + queryTerminal: { reason: 'aborted_tools' }, + }), + ).toBe(true) + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: false, + preflightVetoed: false, + abortReason: 'background', + queryTerminal: undefined, + }), + ).toBe(false) + expect( + shouldContinueBackgroundAfterForegroundQuery({ + didThrow: false, + preflightVetoed: false, + abortReason: 'user', + queryTerminal: { reason: 'aborted_streaming' }, + }), + ).toBe(false) }) test('Config panel exposes Max turns (interactive)', () => { @@ -340,19 +408,4 @@ describe('interactive REPL max-turn cap', () => { exitCode: 1, }) }) - - test('main uses the production Commander parser and wires sessionConfig', () => { - // main.tsx is hard to boot in unit tests; assert the import so the - // shared constant cannot be referenced without being bundled, plus the - // local interactive sessionConfig handoff (same pattern as fallbackModel). - const source = readSourceUp('main.tsx') - expect(source).toMatch( - /import\s*\{[^}]*\bparseMaxTurnsCommanderArgument\b[^}]*\}\s*from\s*'\.\/utils\/replMaxTurns\.js'/, - ) - expect(source).toContain( - 'return parseMaxTurnsCommanderArgument(value);', - ) - const body = objectBody(source, /const sessionConfig = \{/) - expect(body).toContain('maxTurns: options.maxTurns') - }) }) diff --git a/src/utils/taskNotificationIdentity.test.ts b/src/utils/taskNotificationIdentity.test.ts new file mode 100644 index 0000000000..d2795820c9 --- /dev/null +++ b/src/utils/taskNotificationIdentity.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'bun:test' +import { + getTaskNotificationDedupKey, + parseTaskNotificationTaskId, +} from './taskNotificationIdentity.js' + +function taskNotification(taskId: string, summary: string): string { + return ` +${taskId} +/tmp/${taskId}.jsonl +completed +${summary} +` +} + +describe('task notification identity', () => { + test('parses the embedded task id from notification payloads', () => { + expect(parseTaskNotificationTaskId(taskNotification('sabc1234', 'done'))).toBe( + 'sabc1234', + ) + expect(parseTaskNotificationTaskId('no task id here')).toBeUndefined() + }) + + test('dedup keys stay distinct when summary text matches', () => { + const summary = 'Background session "work" completed' + const first = taskNotification('s1111111', summary) + const second = taskNotification('s2222222', summary) + + expect(getTaskNotificationDedupKey(first)).toBe('s1111111') + expect(getTaskNotificationDedupKey(second)).toBe('s2222222') + expect(getTaskNotificationDedupKey(first)).not.toBe( + getTaskNotificationDedupKey(second), + ) + }) + + test('falls back to full content when no task id is present', () => { + const payload = 'hook' + expect(getTaskNotificationDedupKey(payload)).toBe(payload) + }) +}) diff --git a/src/utils/taskNotificationIdentity.ts b/src/utils/taskNotificationIdentity.ts new file mode 100644 index 0000000000..f2644107ae --- /dev/null +++ b/src/utils/taskNotificationIdentity.ts @@ -0,0 +1,22 @@ +import { TASK_ID_TAG } from '../constants/xml.js' + +const TASK_ID_PATTERN = new RegExp( + `<${TASK_ID_TAG}>([^<]+)`, +) + +/** + * Parse the stable task id embedded in a task-notification payload. + */ +export function parseTaskNotificationTaskId( + content: string, +): string | undefined { + return content.match(TASK_ID_PATTERN)?.[1] +} + +/** + * Dedup key for task notifications. Prefer the embedded task id so distinct + * tasks with identical summary text are not collapsed. + */ +export function getTaskNotificationDedupKey(content: string): string { + return parseTaskNotificationTaskId(content) ?? content +} From f74603472ebb7fcc1a790ae3f7bc2a9ed843c7cd Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 18:24:56 -0700 Subject: [PATCH 24/34] fix(repl): resolve latest CodeRabbit inline review findings Dedupe claimed notification batches by task id, restore notifications on pre-registration abort, tighten test isolation, and replace remaining brittle source-text assertions with behavioral coverage. --- src/query/agentStepLimit.test.ts | 12 ++++++-- src/screens/REPL.tsx | 18 ++++------- src/screens/replMaxTurnsProp.test.ts | 36 +++++----------------- src/tasks/LocalMainSessionTask.test.ts | 2 +- src/tasks/LocalMainSessionTask.ts | 6 +++- src/utils/messageQueueManager.test.ts | 15 ++++++++- src/utils/replMaxTurns.ts | 2 +- src/utils/taskNotificationIdentity.test.ts | 25 +++++++++++++++ src/utils/taskNotificationIdentity.ts | 35 +++++++++++++++++++++ 9 files changed, 105 insertions(+), 46 deletions(-) diff --git a/src/query/agentStepLimit.test.ts b/src/query/agentStepLimit.test.ts index b1f8c4d7eb..6501d936a1 100644 --- a/src/query/agentStepLimit.test.ts +++ b/src/query/agentStepLimit.test.ts @@ -17,6 +17,8 @@ import { import { asSystemPrompt } from '../utils/systemPromptType.js' import { countToolUses } from '../tools/AgentTool/agentToolUtils.js' import { AGENT_STEP_LIMIT_TOOL_RESULT_PREFIX } from './agentStepLimit.js' +import type { Terminal } from './transitions.js' +import type { Message } from '../types/message.js' import { dequeueAll, enqueue } from '../utils/messageQueueManager.js' const echoCalls: string[] = [] @@ -510,10 +512,16 @@ describe('agent step limits', () => { foregroundParams.toolUseContext.abortController const turnBudget = createQueryTurnBudget(1) const foregroundGenerator = query({ ...foregroundParams, turnBudget }) - const foregroundYielded: any[] = [] + type ForegroundNext = Awaited< + ReturnType + > + type ForegroundYield = ForegroundNext extends IteratorResult + ? Y + : never + const foregroundYielded: ForegroundYield[] = [] try { - let foregroundReturned: any + let foregroundReturned: Terminal | undefined while (true) { const next = await foregroundGenerator.next() if (next.done) { diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 81c64762e9..9123df91e1 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -291,7 +291,7 @@ import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type M import { setClipboard } from '../ink/termio/osc.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; -import { getTaskNotificationDedupKey } from '../utils/taskNotificationIdentity.js'; +import { dedupeQueuedTaskNotifications } from '../utils/taskNotificationIdentity.js'; // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would @@ -2916,17 +2916,11 @@ export function REPL({ } const notificationMessages = notificationAttachments.map(createAttachmentMessage); - // Deduplicate: if the query loop already yielded a notification into - // messagesRef before we removed it from the queue, skip duplicates. - // Use the embedded task id so distinct tasks with identical summary - // text are not collapsed. - const existingNotificationKeys = new Set(); - for (const m of settledMessages) { - if (m.type === 'attachment' && m.attachment.type === 'queued_command' && m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string') { - existingNotificationKeys.add(getTaskNotificationDedupKey(m.attachment.prompt)); - } - } - const uniqueNotifications = notificationMessages.filter(m => m.attachment.type === 'queued_command' && (typeof m.attachment.prompt !== 'string' || !existingNotificationKeys.has(getTaskNotificationDedupKey(m.attachment.prompt)))); + // Deduplicate against settled transcript keys and within the claimed batch. + const uniqueNotifications = dedupeQueuedTaskNotifications( + settledMessages, + notificationMessages, + ); notificationOwnershipActive = false; return { messages: [...settledMessages, ...uniqueNotifications], diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 674bfd2e10..c8b457b59a 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,6 +1,4 @@ import { afterEach, describe, expect, spyOn, test } from 'bun:test' -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { Command as CommanderCommand, InvalidArgumentError, @@ -35,21 +33,11 @@ import { } from '../utils/replMaxTurns.js' import * as debug from '../utils/debug.js' -const screenDir = import.meta.dirname - const ENV_KEYS = ['OPENCLAUDE_MAX_TURNS', 'CLAUDE_CODE_MAX_TURNS'] as const const savedEnv: Partial> = {} const savedReplMaxTurns = getGlobalConfig().replMaxTurns -function readScreen(name: string): string { - return readFileSync(join(screenDir, name), 'utf8') -} - -function readSourceUp(name: string): string { - return readFileSync(join(screenDir, '..', name), 'utf8') -} - function clearTurnEnv(): void { for (const key of ENV_KEYS) { delete process.env[key] @@ -251,6 +239,14 @@ describe('interactive REPL max-turn cap', () => { expect(REPL_MAX_TURNS_OPTIONS).toEqual([50, 100, 200, 500]) }) + test('headless --max-turns 0 stays distinct from interactive unlimited resolution', () => { + expect(parseMaxTurnsCli('0')).toBe(0) + expect(resolveReplMaxTurns(0)).toBeUndefined() + const help = MAX_TURNS_CLI_DESCRIPTION.toLowerCase() + expect(help).toContain('local interactive mode') + expect(help).toContain('--print mode') + }) + test('background budget handoff preserves identity, settlement, and one-shot ownership', async () => { const handoff = createForegroundTurnBudgetHandoff(50) const budgetRef = { current: handoff } @@ -346,22 +342,6 @@ describe('interactive REPL max-turn cap', () => { ).toBe(false) }) - test('Config panel exposes Max turns (interactive)', () => { - const source = readSourceUp(join('components', 'Settings', 'Config.tsx')) - expect(source).toContain("id: 'replMaxTurns'") - expect(source).toContain("label: 'Max turns (interactive)'") - }) - - test('passes the cap from the resume selector into REPL', () => { - const source = readScreen('ResumeConversation.tsx') - const repl = source.slice( - source.indexOf('', source.indexOf(' { // Commander wraps long option help across lines; collapse whitespace. const help = createMaxTurnsCliProgram() diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index ede19534f5..2621fa1bb2 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -76,6 +76,7 @@ afterEach(async () => { try { await _clearOutputsForTest() await flushSessionStorage() + } finally { resetProjectForTesting() _resetTaskOutputDirForTest() setClaudeConfigHomeDirForTesting(originalConfigDir) @@ -89,7 +90,6 @@ afterEach(async () => { } getClaudeTempDir.cache?.clear?.() await rm(testRoot, { recursive: true, force: true }) - } finally { releaseSharedMutationLock() } }) diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 85569bd918..171b5346e7 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -563,7 +563,11 @@ export function startBackgroundSession({ completeMainSessionTask(taskId, true, setAppState) } catch (error) { if (abortSignal.aborted) { - if (taskRegistered) finishAbortedTask() + if (taskRegistered) { + finishAbortedTask() + } else { + restorePreDispatchNotifications() + } return } logError(error) diff --git a/src/utils/messageQueueManager.test.ts b/src/utils/messageQueueManager.test.ts index bfe893e243..63eaaaf292 100644 --- a/src/utils/messageQueueManager.test.ts +++ b/src/utils/messageQueueManager.test.ts @@ -1,4 +1,4 @@ -import { afterEach, expect, test } from 'bun:test' +import { afterEach, beforeEach, expect, test } from 'bun:test' import { dequeue, dequeueAll, @@ -9,8 +9,21 @@ import { subscribeToCommandQueue, } from './messageQueueManager.js' +beforeEach(() => resetCommandQueue()) afterEach(() => resetCommandQueue()) +test('prepend ignores an empty array', () => { + const notifications: number[] = [] + const unsubscribe = subscribeToCommandQueue(() => notifications.push(1)) + try { + prepend([]) + expect(getCommandQueue()).toEqual([]) + expect(notifications).toHaveLength(0) + } finally { + unsubscribe() + } +}) + test('prepend restores commands ahead of later enqueues in FIFO order', () => { const notifications: number[] = [] const unsubscribe = subscribeToCommandQueue(() => notifications.push(1)) diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index c9af8da466..aea836ab8c 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -14,7 +14,7 @@ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const * Keep remote-backed sessions explicitly out of scope in this string. */ export const MAX_TURNS_CLI_DESCRIPTION = - 'Maximum number of agentic turns per prompt. Set to 0 for unlimited turns (use with caution). In local interactive mode this overrides the default 50-turn REPL query cap (also configurable via OPENCLAUDE_MAX_TURNS or /config). Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' + 'Maximum number of agentic turns per prompt. In local interactive mode, set to 0 for unlimited turns (use with caution). This overrides the default 50-turn REPL query cap and is also configurable via OPENCLAUDE_MAX_TURNS or /config. Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' export function parseMaxTurnsCli(value: string): number { const parsed = value.trim() === '' ? Number.NaN : Number(value) diff --git a/src/utils/taskNotificationIdentity.test.ts b/src/utils/taskNotificationIdentity.test.ts index d2795820c9..db8b51ce40 100644 --- a/src/utils/taskNotificationIdentity.test.ts +++ b/src/utils/taskNotificationIdentity.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test' import { + dedupeQueuedTaskNotifications, getTaskNotificationDedupKey, parseTaskNotificationTaskId, } from './taskNotificationIdentity.js' +import { createAttachmentMessage } from './attachments.js' function taskNotification(taskId: string, summary: string): string { return ` @@ -37,4 +39,27 @@ describe('task notification identity', () => { const payload = 'hook' expect(getTaskNotificationDedupKey(payload)).toBe(payload) }) + + test('dedupes duplicate task ids within a claimed notification batch', () => { + const summary = 'Background session "work" completed' + const first = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: taskNotification('s1111111', summary), + }) + const duplicate = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: taskNotification('s1111111', summary), + }) + const distinct = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: taskNotification('s2222222', summary), + }) + + expect( + dedupeQueuedTaskNotifications([], [first, duplicate, distinct]), + ).toEqual([first, distinct]) + }) }) diff --git a/src/utils/taskNotificationIdentity.ts b/src/utils/taskNotificationIdentity.ts index f2644107ae..a8171ef9a0 100644 --- a/src/utils/taskNotificationIdentity.ts +++ b/src/utils/taskNotificationIdentity.ts @@ -1,4 +1,5 @@ import { TASK_ID_TAG } from '../constants/xml.js' +import type { Message } from '../types/message.js' const TASK_ID_PATTERN = new RegExp( `<${TASK_ID_TAG}>([^<]+)`, @@ -20,3 +21,37 @@ export function parseTaskNotificationTaskId( export function getTaskNotificationDedupKey(content: string): string { return parseTaskNotificationTaskId(content) ?? content } + +export function dedupeQueuedTaskNotifications( + settledMessages: readonly Message[], + notificationMessages: readonly Message[], +): Message[] { + const existingNotificationKeys = new Set() + for (const message of settledMessages) { + if ( + message.type === 'attachment' && + message.attachment.type === 'queued_command' && + message.attachment.commandMode === 'task-notification' && + typeof message.attachment.prompt === 'string' + ) { + existingNotificationKeys.add( + getTaskNotificationDedupKey(message.attachment.prompt), + ) + } + } + return notificationMessages.filter(message => { + if ( + message.type !== 'attachment' || + message.attachment.type !== 'queued_command' || + typeof message.attachment.prompt !== 'string' + ) { + return true + } + const key = getTaskNotificationDedupKey(message.attachment.prompt) + if (existingNotificationKeys.has(key)) { + return false + } + existingNotificationKeys.add(key) + return true + }) +} From fadcdc6548bb5545462a869dec799427b5baf2f8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 18:32:09 -0700 Subject: [PATCH 25/34] fix(repl): keep notification restore active until provider dispatch Stop clearing notification ownership when preparation succeeds so pre-dispatch aborts can restore claimed queue items, and commit ownership once the provider starts. Add regression coverage for the abort path and headless max-turns zero. --- src/screens/REPL.tsx | 4 ++- src/screens/replMaxTurnsProp.test.ts | 5 ++++ src/tasks/LocalMainSessionTask.test.ts | 40 ++++++++++++++++++++++++++ src/tasks/LocalMainSessionTask.ts | 8 +++++- 4 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 9123df91e1..30188c1079 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -2921,10 +2921,12 @@ export function REPL({ settledMessages, notificationMessages, ); - notificationOwnershipActive = false; return { messages: [...settledMessages, ...uniqueNotifications], restoreNotificationsIfUnsent, + commitNotificationOwnership: () => { + notificationOwnershipActive = false; + }, queryParams: { systemPrompt, userContext, diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index c8b457b59a..16b811bf53 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -13,6 +13,7 @@ import { shouldShowReplMaxTurnsUnlimitedWarning, waitForForegroundTurnBudgetSettlement, } from './replMaxTurns.js' +import { createQueryTurnBudget } from '../query.js' import { DEFAULT_GLOBAL_CONFIG, GLOBAL_CONFIG_KEYS, @@ -242,6 +243,10 @@ describe('interactive REPL max-turn cap', () => { test('headless --max-turns 0 stays distinct from interactive unlimited resolution', () => { expect(parseMaxTurnsCli('0')).toBe(0) expect(resolveReplMaxTurns(0)).toBeUndefined() + const headlessTurnBudget = createQueryTurnBudget(parseMaxTurnsCli('0')) + expect(headlessTurnBudget.maxTurns).toBe(0) + // Headless passes the CLI value through; falsy maxTurns disables turn-cap guards. + expect(Boolean(headlessTurnBudget.maxTurns)).toBe(false) const help = MAX_TURNS_CLI_DESCRIPTION.toLowerCase() expect(help).toContain('local interactive mode') expect(help).toContain('--print mode') diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 2621fa1bb2..2dce66ea2f 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -13,6 +13,7 @@ import type { Terminal } from '../query/transitions.js' import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' import type { Message } from '../types/message.js' import { createAttachmentMessage } from '../utils/attachments.js' +import { createAssistantMessage } from '../utils/messages.js' import { getClaudeConfigHomeDir, getClaudeConfigHomeDirOverrideForTesting, @@ -140,6 +141,45 @@ describe('LocalMainSessionTask', () => { expect(state.tasks[taskId]).toBeUndefined() }) + test('restores claimed notifications when background aborts before provider dispatch', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + let restoreCalls = 0 + let releaseQuery!: () => void + const queryBlocked = new Promise(resolve => { + releaseQuery = resolve + }) + let taskRegistered = false + + const { taskId, abortController } = startBackgroundSession({ + description: 'pre-dispatch abort', + setAppState, + prepare: async () => ({ + messages: [], + restoreNotificationsIfUnsent: () => { + restoreCalls++ + }, + queryParams: {} as Omit, + }), + onRegistered: () => { + taskRegistered = true + }, + queryImpl: (async function* (): AsyncGenerator { + await queryBlocked + yield createAssistantMessage({ content: 'late' }) + return { reason: 'completed' } + }) as typeof import('../query.js').query, + }) + + await waitFor(() => taskRegistered, 'task registration') + abortController.abort('stopped') + releaseQuery() + await waitFor(() => restoreCalls === 1, 'notification restore') + expect(state.tasks[taskId]?.status).toBe('killed') + }) + test('retains a max-turn terminal attachment in task messages', async () => { let state = getDefaultAppState() const setAppState = (update: (previous: AppState) => AppState): void => { diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 171b5346e7..1ee0c703fb 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -359,6 +359,7 @@ export function startBackgroundSession({ | { messages: Message[] restoreNotificationsIfUnsent?: () => void + commitNotificationOwnership?: () => void queryParams: Omit } | null @@ -380,6 +381,7 @@ export function startBackgroundSession({ let taskRegistered = false let providerRequestStarted = false let restoreNotificationsIfUnsent: (() => void) | undefined + let commitNotificationOwnership: (() => void) | undefined const restorePreDispatchNotifications = (): void => { if (providerRequestStarted) return @@ -442,6 +444,7 @@ export function startBackgroundSession({ return } restoreNotificationsIfUnsent = prepared.restoreNotificationsIfUnsent + commitNotificationOwnership = prepared.commitNotificationOwnership if (abortSignal.aborted) { restorePreDispatchNotifications() return @@ -475,7 +478,10 @@ export function startBackgroundSession({ ...queryParams, onModelRequestStart: () => { queryParams.onModelRequestStart?.() - if (!abortSignal.aborted) providerRequestStarted = true + if (!abortSignal.aborted) { + providerRequestStarted = true + commitNotificationOwnership?.() + } }, })) { if (abortSignal.aborted) { From 465bf4733b97448f905b48fadb67e4c89663d692 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 18:40:26 -0700 Subject: [PATCH 26/34] test(repl): cover post-dispatch ownership and headless max-turns 0 Add regression tests for notification restore after provider dispatch commits ownership, and assert headless --max-turns 0 reaches query() without interactive resolution stripping the value. --- src/cli/printMaxTurns.test.ts | 98 ++++++++++++++++++++++++++ src/tasks/LocalMainSessionTask.test.ts | 48 +++++++++++++ 2 files changed, 146 insertions(+) create mode 100644 src/cli/printMaxTurns.test.ts diff --git a/src/cli/printMaxTurns.test.ts b/src/cli/printMaxTurns.test.ts new file mode 100644 index 0000000000..45cd95dc5c --- /dev/null +++ b/src/cli/printMaxTurns.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, spyOn, test } from 'bun:test' + +import * as queryModule from '../query.js' +import { type QueryParams } from '../query.js' +import type { QueryDeps } from '../query/deps.js' +import { asSystemPrompt } from '../utils/systemPromptType.js' +import { + createAssistantMessage, + createUserMessage, +} from '../utils/messages.js' +import { parseMaxTurnsCli, resolveReplMaxTurns } from '../utils/replMaxTurns.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, + } +} + +describe('headless --print max-turns', () => { + 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() + }) +}) diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 2dce66ea2f..9c58ac8f7a 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -180,6 +180,54 @@ describe('LocalMainSessionTask', () => { expect(state.tasks[taskId]?.status).toBe('killed') }) + test('does not restore notifications after provider dispatch commits ownership', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + let restoreCalls = 0 + let ownershipActive = true + let releaseQuery!: () => void + const queryBlocked = new Promise(resolve => { + releaseQuery = resolve + }) + let taskRegistered = false + + const { taskId, abortController } = startBackgroundSession({ + description: 'post-dispatch abort', + setAppState, + prepare: async () => ({ + messages: [], + restoreNotificationsIfUnsent: () => { + if (!ownershipActive) return + ownershipActive = false + restoreCalls++ + }, + commitNotificationOwnership: () => { + ownershipActive = false + }, + queryParams: {} as Omit, + }), + onRegistered: () => { + taskRegistered = true + }, + queryImpl: (async function* ( + params, + ): AsyncGenerator { + params.onModelRequestStart?.() + await queryBlocked + yield createAssistantMessage({ content: 'late' }) + return { reason: 'completed' } + }) as typeof import('../query.js').query, + }) + + await waitFor(() => taskRegistered, 'task registration') + abortController.abort('stopped') + releaseQuery() + await waitFor(() => state.tasks[taskId]?.status === 'killed', 'task killed') + expect(restoreCalls).toBe(0) + }) + test('retains a max-turn terminal attachment in task messages', async () => { let state = getDefaultAppState() const setAppState = (update: (previous: AppState) => AppState): void => { From 4191f3dd1349aac174d353a2d0a932b308addb5d Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 19:10:49 -0700 Subject: [PATCH 27/34] fix(repl): restore only embeddable notifications on Ctrl+B handoff abort Track the deduped successor subset when restoring claimed main-thread task notifications so items already in the settled foreground transcript are not re-queued. Clarify that agent-scoped notifications intentionally stay on their owner drain path (issue #2079 scope is interactive turn caps only). --- src/screens/REPL.tsx | 20 +++++-- src/utils/taskNotificationIdentity.test.ts | 51 ++++++++++++++++++ src/utils/taskNotificationIdentity.ts | 61 ++++++++++++++++++++-- 3 files changed, 124 insertions(+), 8 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 30188c1079..79637a6c84 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -291,7 +291,7 @@ import { useMessageActions, MessageActionsKeybindings, MessageActionsBar, type M import { setClipboard } from '../ink/termio/osc.js'; import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; -import { dedupeQueuedTaskNotifications } from '../utils/taskNotificationIdentity.js'; +import { dedupeQueuedTaskNotifications, filterClaimedTaskNotificationsForRestore, pendingCommandsForEmbeddedNotifications } from '../utils/taskNotificationIdentity.js'; // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would @@ -2875,18 +2875,24 @@ export function REPL({ // and allow a new prompt. Capture this continuation's transcript before // any preparation await can observe that later foreground turn. const settledMessages = [...messagesRef.current]; - // Claim foreground-produced notifications immediately after the - // settlement decision, before any async context preparation can let - // another queue consumer take ownership. + // Claim main-thread notifications only. Subagent-addressed entries keep + // their owner-scoped drain path (QueuedCommand.agentId); issue #2079 is + // about configurable interactive turn caps, not queue isolation. const pendingNotifications = removeByFilter( cmd => cmd.mode === 'task-notification' && cmd.agentId === undefined, ); + let restorableNotifications = filterClaimedTaskNotificationsForRestore( + pendingNotifications, + settledMessages, + ); let notificationOwnershipActive = true; const restoreNotificationsIfUnsent = () => { if (!notificationOwnershipActive) return; notificationOwnershipActive = false; - prepend(pendingNotifications); + if (restorableNotifications.length > 0) { + prepend(restorableNotifications); + } }; try { const toolUseContext = getToolUseContext(settledMessages, [], backgroundAbortController, mainLoopModel); @@ -2921,6 +2927,10 @@ export function REPL({ settledMessages, notificationMessages, ); + restorableNotifications = pendingCommandsForEmbeddedNotifications( + pendingNotifications, + uniqueNotifications, + ); return { messages: [...settledMessages, ...uniqueNotifications], restoreNotificationsIfUnsent, diff --git a/src/utils/taskNotificationIdentity.test.ts b/src/utils/taskNotificationIdentity.test.ts index db8b51ce40..a10b1e4e2d 100644 --- a/src/utils/taskNotificationIdentity.test.ts +++ b/src/utils/taskNotificationIdentity.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from 'bun:test' import { dedupeQueuedTaskNotifications, + filterClaimedTaskNotificationsForRestore, getTaskNotificationDedupKey, parseTaskNotificationTaskId, + pendingCommandsForEmbeddedNotifications, } from './taskNotificationIdentity.js' import { createAttachmentMessage } from './attachments.js' @@ -62,4 +64,53 @@ describe('task notification identity', () => { dedupeQueuedTaskNotifications([], [first, duplicate, distinct]), ).toEqual([first, distinct]) }) + + test('restore excludes notifications already present in the settled transcript', () => { + const summary = 'Background session "work" completed' + const settledPrompt = taskNotification('s1111111', summary) + const settled = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: settledPrompt, + }) + const pending = [ + { + value: settledPrompt, + mode: 'task-notification' as const, + priority: 'later' as const, + }, + { + value: taskNotification('s2222222', summary), + mode: 'task-notification' as const, + priority: 'later' as const, + }, + ] + + expect( + filterClaimedTaskNotificationsForRestore(pending, [settled]), + ).toEqual([pending[1]]) + }) + + test('restore maps embedded successor notifications back to claimed commands', () => { + const summary = 'Background session "work" completed' + const first = { + value: taskNotification('s1111111', summary), + mode: 'task-notification' as const, + priority: 'later' as const, + } + const second = { + value: taskNotification('s2222222', summary), + mode: 'task-notification' as const, + priority: 'later' as const, + } + const embedded = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: first.value, + }) + + expect( + pendingCommandsForEmbeddedNotifications([first, second], [embedded]), + ).toEqual([first]) + }) }) diff --git a/src/utils/taskNotificationIdentity.ts b/src/utils/taskNotificationIdentity.ts index a8171ef9a0..a20e816feb 100644 --- a/src/utils/taskNotificationIdentity.ts +++ b/src/utils/taskNotificationIdentity.ts @@ -1,5 +1,6 @@ import { TASK_ID_TAG } from '../constants/xml.js' import type { Message } from '../types/message.js' +import type { QueuedCommand } from '../types/textInputTypes.js' const TASK_ID_PATTERN = new RegExp( `<${TASK_ID_TAG}>([^<]+)`, @@ -22,10 +23,16 @@ export function getTaskNotificationDedupKey(content: string): string { return parseTaskNotificationTaskId(content) ?? content } -export function dedupeQueuedTaskNotifications( +function getQueuedCommandNotificationKey( + command: QueuedCommand, +): string | undefined { + if (typeof command.value !== 'string') return undefined + return getTaskNotificationDedupKey(command.value) +} + +function collectSettledNotificationKeys( settledMessages: readonly Message[], - notificationMessages: readonly Message[], -): Message[] { +): Set { const existingNotificationKeys = new Set() for (const message of settledMessages) { if ( @@ -39,6 +46,54 @@ export function dedupeQueuedTaskNotifications( ) } } + return existingNotificationKeys +} + +/** + * Claimed notifications already present in the settled foreground transcript + * must not be restored to the queue on handoff abort. + */ +export function filterClaimedTaskNotificationsForRestore( + pendingNotifications: readonly QueuedCommand[], + settledMessages: readonly Message[], +): QueuedCommand[] { + const existingNotificationKeys = collectSettledNotificationKeys(settledMessages) + return pendingNotifications.filter(command => { + const key = getQueuedCommandNotificationKey(command) + return key === undefined || !existingNotificationKeys.has(key) + }) +} + +/** + * Map embedded handoff notification attachments back to their claimed queue + * commands so restore only returns notifications actually woven into the + * successor batch. + */ +export function pendingCommandsForEmbeddedNotifications( + pendingNotifications: readonly QueuedCommand[], + embeddedNotifications: readonly Message[], +): QueuedCommand[] { + const embeddedKeys = new Set() + for (const message of embeddedNotifications) { + if ( + message.type === 'attachment' && + message.attachment.type === 'queued_command' && + typeof message.attachment.prompt === 'string' + ) { + embeddedKeys.add(getTaskNotificationDedupKey(message.attachment.prompt)) + } + } + return pendingNotifications.filter(command => { + const key = getQueuedCommandNotificationKey(command) + return key !== undefined && embeddedKeys.has(key) + }) +} + +export function dedupeQueuedTaskNotifications( + settledMessages: readonly Message[], + notificationMessages: readonly Message[], +): Message[] { + const existingNotificationKeys = collectSettledNotificationKeys(settledMessages) return notificationMessages.filter(message => { if ( message.type !== 'attachment' || From 2db82eb273c0c98358d1911f33467b9a4b4e30a0 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 19:38:30 -0700 Subject: [PATCH 28/34] fix(repl): address review findings on background handoff Commit notification ownership when background sessions complete without provider dispatch, forward all task notifications on Ctrl+B again, and restore deferred max-turn cap attachments when continuation is cancelled. --- src/screens/REPL.tsx | 20 ++++++++- src/screens/replMaxTurns.ts | 28 +++++++++++++ src/screens/replMaxTurnsProp.test.ts | 28 +++++++++++++ src/tasks/LocalMainSessionTask.test.ts | 47 ++++++++++++++++++++++ src/tasks/LocalMainSessionTask.ts | 33 ++++++++++----- src/utils/taskNotificationIdentity.test.ts | 18 +++++++++ src/utils/taskNotificationIdentity.ts | 1 + 7 files changed, 165 insertions(+), 10 deletions(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 79637a6c84..97eaaff916 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -39,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { claimBackgroundTurnBudget, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, shouldShowReplMaxTurnsUnlimitedWarning, shouldContinueBackgroundAfterForegroundQuery, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; +import { claimBackgroundTurnBudget, computeDeferredMaxTurnsCapForBackgroundHandoff, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, shouldShowReplMaxTurnsUnlimitedWarning, shouldContinueBackgroundAfterForegroundQuery, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -2860,6 +2860,16 @@ export function REPL({ // cap while the foreground query is winding down. const backgroundHandoff = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); if (!backgroundHandoff) return; + const restoreDeferredMaxTurnsCap = () => { + const cap = backgroundHandoff.deferredMaxTurnsCap; + if (!cap) return; + backgroundHandoff.deferredMaxTurnsCap = undefined; + setMessages(prev => [...prev, createAttachmentMessage({ + type: 'max_turns_reached', + maxTurns: cap.maxTurns, + turnCount: cap.turnCount + })]); + }; const backgroundSession = startBackgroundSession({ prepare: async backgroundAbortController => { // The foreground owns transcript completion. Wait until its abort path @@ -2961,12 +2971,14 @@ export function REPL({ setAppState, agentDefinition: mainThreadAgentDefinition, onPreparationError: () => { + restoreDeferredMaxTurnsCap(); addNotification({ key: 'background-session-start-failed', text: 'Could not start the background session. The current request was cancelled.', priority: 'high', }); }, + onContinuationCancelled: restoreDeferredMaxTurnsCap, onRegistered: controller => { setAbortController(current => current === controller ? null : current, @@ -3383,6 +3395,12 @@ export function REPL({ abortReason: abortController.signal.reason, queryTerminal, }); + if (shouldContinueBackground) { + const deferredCap = computeDeferredMaxTurnsCapForBackgroundHandoff(abortController.signal.reason, queryTerminal, turnBudget.maxTurns, turnBudget.turnsStarted); + if (deferredCap) { + turnBudgetHandoff.deferredMaxTurnsCap = deferredCap; + } + } releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); // A provider response can hand off to tools before the assistant turn // finishes. Keep correction ownership through that work (and retries), diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 91b80418a8..1c3471c4a1 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -3,6 +3,7 @@ import { type QueryTurnBudget, } from '../query.js' import type { Terminal as QueryTerminal } from '../query/transitions.js' +import { normalizeAbortReason } from '../utils/abortReasons.js' import { getReplMaxTurnsWarning } from '../utils/replMaxTurns.js' export { @@ -60,10 +61,37 @@ export function shouldContinueBackgroundAfterForegroundQuery({ type MutableRef = { current: T } +export type DeferredMaxTurnsCap = { + maxTurns: number + turnCount: number +} + export type ForegroundTurnBudgetHandoff = { budget: QueryTurnBudget settled: Promise settle: (shouldContinue: boolean) => void + /** Cap suppressed on foreground `background` abort; restore if handoff is cancelled. */ + deferredMaxTurnsCap?: DeferredMaxTurnsCap +} + +export function computeDeferredMaxTurnsCapForBackgroundHandoff( + abortReason: unknown, + queryTerminal: QueryTerminal | undefined, + maxTurns: number | undefined, + turnsStarted: number, +): DeferredMaxTurnsCap | undefined { + if ( + normalizeAbortReason(abortReason) !== 'background' || + queryTerminal?.reason !== 'aborted_tools' || + maxTurns === undefined + ) { + return undefined + } + const turnCount = turnsStarted + 1 + if (turnCount <= maxTurns) { + return undefined + } + return { maxTurns, turnCount } } export function createForegroundTurnBudgetHandoff( diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 16b811bf53..285a94905d 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -6,6 +6,7 @@ import { } from '@commander-js/extra-typings' import { claimBackgroundTurnBudget, + computeDeferredMaxTurnsCapForBackgroundHandoff, createForegroundTurnBudgetHandoff, isLocalInteractiveMaxTurnsSession, releaseForegroundTurnBudget, @@ -296,6 +297,33 @@ describe('interactive REPL max-turn cap', () => { await expect(wait).resolves.toBeNull() }) + test('defers max-turn cap restoration when foreground suppresses it for background handoff', () => { + expect( + computeDeferredMaxTurnsCapForBackgroundHandoff( + 'background', + { reason: 'aborted_tools' }, + 1, + 1, + ), + ).toEqual({ maxTurns: 1, turnCount: 2 }) + expect( + computeDeferredMaxTurnsCapForBackgroundHandoff( + 'background', + { reason: 'aborted_streaming' }, + 1, + 1, + ), + ).toBeUndefined() + expect( + computeDeferredMaxTurnsCapForBackgroundHandoff( + 'user', + { reason: 'aborted_tools' }, + 1, + 1, + ), + ).toBeUndefined() + }) + test('does not continue a background handoff after the foreground query throws', () => { expect( shouldContinueBackgroundAfterForegroundQuery({ diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 9c58ac8f7a..18b4d831ff 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -9,6 +9,7 @@ import { releaseSharedMutationLock, } from '../test/sharedMutationLock.js' import type { QueryParams } from '../query.js' +import { createQueryTurnBudget } from '../query.js' import type { Terminal } from '../query/transitions.js' import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' import type { Message } from '../types/message.js' @@ -228,6 +229,49 @@ describe('LocalMainSessionTask', () => { expect(restoreCalls).toBe(0) }) + test('does not restore merged notifications when background completes at turn cap without provider dispatch', async () => { + let state = getDefaultAppState() + const setAppState = (update: (previous: AppState) => AppState): void => { + state = update(state) + } + let restoreCalls = 0 + let commitCalls = 0 + const notificationAttachment = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt: + 'sabc1234done', + }) + + const { taskId } = startBackgroundSession({ + description: 'turn-cap completion', + setAppState, + prepare: async () => ({ + messages: [notificationAttachment], + restoreNotificationsIfUnsent: () => { + restoreCalls++ + }, + commitNotificationOwnership: () => { + commitCalls++ + }, + queryParams: { + turnBudget: createQueryTurnBudget(1), + } as Omit, + }), + queryImpl: (async function* (): AsyncGenerator { + return { reason: 'max_turns', turnCount: 2 } + }) as typeof import('../query.js').query, + }) + + await waitFor( + () => state.tasks[taskId]?.status === 'completed', + 'background task completion', + ) + + expect(restoreCalls).toBe(0) + expect(commitCalls).toBe(1) + }) + test('retains a max-turn terminal attachment in task messages', async () => { let state = getDefaultAppState() const setAppState = (update: (previous: AppState) => AppState): void => { @@ -271,6 +315,7 @@ describe('LocalMainSessionTask', () => { } const failure = new Error('context failed') const errors: unknown[] = [] + const cancelled: boolean[] = [] const { taskId } = startBackgroundSession({ description: 'failing preparation', @@ -279,11 +324,13 @@ describe('LocalMainSessionTask', () => { throw failure }, onPreparationError: error => errors.push(error), + onContinuationCancelled: () => cancelled.push(true), }) await waitFor(() => errors.length === 1, 'preparation failure callback') expect(errors).toEqual([failure]) + expect(cancelled).toEqual([true]) expect(state.tasks[taskId]).toBeUndefined() }) diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 1ee0c703fb..9c5bf96b82 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -352,6 +352,7 @@ export function startBackgroundSession({ agentDefinition, queryImpl = query, onPreparationError, + onContinuationCancelled, onRegistered, onSettled, }: { @@ -369,6 +370,7 @@ export function startBackgroundSession({ agentDefinition?: AgentDefinition queryImpl?: typeof query onPreparationError?: (error: unknown) => void + onContinuationCancelled?: () => void onRegistered?: (abortController: AbortController) => void onSettled?: (abortController: AbortController) => void }): BackgroundSessionHandle { @@ -447,15 +449,25 @@ export function startBackgroundSession({ commitNotificationOwnership = prepared.commitNotificationOwnership if (abortSignal.aborted) { restorePreDispatchNotifications() + onContinuationCancelled?.() return } - registerMainSessionTask( - description, - setAppState, - agentDefinition, - abortController, - taskId, - ) + try { + registerMainSessionTask( + description, + setAppState, + agentDefinition, + abortController, + taskId, + ) + } catch (error) { + restorePreDispatchNotifications() + if (abortSignal.aborted) { + onContinuationCancelled?.() + return + } + throw error + } taskRegistered = true onRegistered?.(abortController) const { messages, queryParams } = prepared @@ -565,7 +577,7 @@ export function startBackgroundSession({ finishAbortedTask() return } - restorePreDispatchNotifications() + commitNotificationOwnership?.() completeMainSessionTask(taskId, true, setAppState) } catch (error) { if (abortSignal.aborted) { @@ -573,15 +585,18 @@ export function startBackgroundSession({ finishAbortedTask() } else { restorePreDispatchNotifications() + onContinuationCancelled?.() } return } logError(error) if (taskRegistered) { - restorePreDispatchNotifications() + commitNotificationOwnership?.() completeMainSessionTask(taskId, false, setAppState) } else { + restorePreDispatchNotifications() onPreparationError?.(error) + onContinuationCancelled?.() } } finally { onSettled?.(abortController) diff --git a/src/utils/taskNotificationIdentity.test.ts b/src/utils/taskNotificationIdentity.test.ts index a10b1e4e2d..f190559f1d 100644 --- a/src/utils/taskNotificationIdentity.test.ts +++ b/src/utils/taskNotificationIdentity.test.ts @@ -113,4 +113,22 @@ describe('task notification identity', () => { pendingCommandsForEmbeddedNotifications([first, second], [embedded]), ).toEqual([first]) }) + + test('does not dedupe non-task-notification queued commands in the batch', () => { + const prompt = taskNotification('s1111111', 'done') + const taskAttachment = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'task-notification', + prompt, + }) + const promptAttachment = createAttachmentMessage({ + type: 'queued_command', + commandMode: 'prompt', + prompt, + }) + + expect(dedupeQueuedTaskNotifications([], [taskAttachment, promptAttachment])).toEqual( + [taskAttachment, promptAttachment], + ) + }) }) diff --git a/src/utils/taskNotificationIdentity.ts b/src/utils/taskNotificationIdentity.ts index a20e816feb..157d0c8914 100644 --- a/src/utils/taskNotificationIdentity.ts +++ b/src/utils/taskNotificationIdentity.ts @@ -98,6 +98,7 @@ export function dedupeQueuedTaskNotifications( if ( message.type !== 'attachment' || message.attachment.type !== 'queued_command' || + message.attachment.commandMode !== 'task-notification' || typeof message.attachment.prompt !== 'string' ) { return true From 3a400633eed5b581bcb1d89d851e7da5af37d357 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 20:11:09 -0700 Subject: [PATCH 29/34] fix(repl): guard deferred cap restore and skip remote turn limits Anchor deferred max-turn restoration to the handed-off transcript tail so a cancelled Ctrl+B handoff cannot attach the prior prompt's cap to a newer turn. Apply the interactive turn cap only in local sessions and align remote-session docs/help wording. --- docs/advanced-setup.md | 2 +- src/components/SessionBackgroundHint.tsx | 2 + src/screens/REPL.tsx | 18 ++++++-- src/screens/replMaxTurns.ts | 32 +++++++++++++- src/screens/replMaxTurnsProp.test.ts | 53 ++++++++++++++++++++++++ src/utils/replMaxTurns.ts | 2 +- 6 files changed, 102 insertions(+), 7 deletions(-) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index fc4772b9f2..8f096f55c9 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -478,7 +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`), which execute on the remote side. | +| `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 | diff --git a/src/components/SessionBackgroundHint.tsx b/src/components/SessionBackgroundHint.tsx index bd5e224c18..5be8328e53 100644 --- a/src/components/SessionBackgroundHint.tsx +++ b/src/components/SessionBackgroundHint.tsx @@ -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(); } diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index 97eaaff916..a602cb09f4 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -39,7 +39,7 @@ import { logForDebugging } from '../utils/debug.js'; import { QueryGuard } from '../utils/QueryGuard.js'; import { getQueryGuardOptionsFromEnv } from '../utils/queryGuardConfig.js'; import { QueryLifecycleOperationTracker, formatQueryLifecycleAbortSignalReason, formatQueryLifecycleLogMessage, getQueryTerminalReason, type QueryActiveOperationSnapshot, type QueryGuardTimeoutInfo, type QueryLifecycleContext, type QueryTerminalReason } from '../utils/queryLifecycle.js'; -import { claimBackgroundTurnBudget, computeDeferredMaxTurnsCapForBackgroundHandoff, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurns, shouldShowReplMaxTurnsUnlimitedWarning, shouldContinueBackgroundAfterForegroundQuery, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; +import { claimBackgroundTurnBudget, canRestoreDeferredMaxTurnsCap, computeDeferredMaxTurnsCapForBackgroundHandoff, createForegroundTurnBudgetHandoff, getReplMaxTurnsWarning, releaseForegroundTurnBudget, resolveReplMaxTurnsForSession, shouldShowReplMaxTurnsUnlimitedWarning, shouldContinueBackgroundAfterForegroundQuery, waitForForegroundTurnBudgetSettlement, type ForegroundTurnBudgetHandoff } from './replMaxTurns.js'; import { createCombinedAbortSignal } from '../utils/combinedAbortSignal.js'; import { isEnvTruthy } from '../utils/envUtils.js'; import { formatTokens, truncateToWidth } from '../utils/format.js'; @@ -2862,7 +2862,7 @@ export function REPL({ if (!backgroundHandoff) return; const restoreDeferredMaxTurnsCap = () => { const cap = backgroundHandoff.deferredMaxTurnsCap; - if (!cap) return; + if (!cap || !canRestoreDeferredMaxTurnsCap(backgroundHandoff, messagesRef.current)) return; backgroundHandoff.deferredMaxTurnsCap = undefined; setMessages(prev => [...prev, createAttachmentMessage({ type: 'max_turns_reached', @@ -2885,6 +2885,8 @@ export function REPL({ // and allow a new prompt. Capture this continuation's transcript before // any preparation await can observe that later foreground turn. const settledMessages = [...messagesRef.current]; + backgroundHandoff.settledTranscriptTailUuid = + settledMessages.at(-1)?.uuid ?? null; // Claim main-thread notifications only. Subagent-addressed entries keep // their owner-scoped drain path (QueuedCommand.agentId); issue #2079 is // about configurable interactive turn caps, not queue isolation. @@ -3308,7 +3310,13 @@ export function REPL({ lifecycleTracker.clear(); const thisGeneration = startResult.generation; backgroundHandoffStartedRef.current = false; - const turnBudgetHandoff = createForegroundTurnBudgetHandoff(resolveReplMaxTurns(maxTurnsProp)); + const turnBudgetHandoff = createForegroundTurnBudgetHandoff( + resolveReplMaxTurnsForSession(maxTurnsProp, { + isRemoteSession, + directConnectConfig, + sshSession, + }), + ); const turnBudget = turnBudgetHandoff.budget; foregroundTurnBudgetRef.current = turnBudgetHandoff; const queryContext = startResult.context; @@ -3399,6 +3407,8 @@ export function REPL({ const deferredCap = computeDeferredMaxTurnsCapForBackgroundHandoff(abortController.signal.reason, queryTerminal, turnBudget.maxTurns, turnBudget.turnsStarted); if (deferredCap) { turnBudgetHandoff.deferredMaxTurnsCap = deferredCap; + turnBudgetHandoff.settledTranscriptTailUuid = + persistentMessages.at(-1)?.uuid ?? null; } } releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); @@ -3562,7 +3572,7 @@ export function REPL({ } } } - }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, maxTurnsProp]); + }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, maxTurnsProp, isRemoteSession, directConnectConfig, sshSession]); // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message diff --git a/src/screens/replMaxTurns.ts b/src/screens/replMaxTurns.ts index 1c3471c4a1..b282847e6d 100644 --- a/src/screens/replMaxTurns.ts +++ b/src/screens/replMaxTurns.ts @@ -4,7 +4,7 @@ import { } from '../query.js' import type { Terminal as QueryTerminal } from '../query/transitions.js' import { normalizeAbortReason } from '../utils/abortReasons.js' -import { getReplMaxTurnsWarning } from '../utils/replMaxTurns.js' +import { getReplMaxTurnsWarning, resolveReplMaxTurns } from '../utils/replMaxTurns.js' export { DEFAULT_REPL_MAX_TURNS, @@ -72,6 +72,36 @@ export type ForegroundTurnBudgetHandoff = { settle: (shouldContinue: boolean) => void /** Cap suppressed on foreground `background` abort; restore if handoff is cancelled. */ deferredMaxTurnsCap?: DeferredMaxTurnsCap + /** + * Last settled transcript message before a cancelled handoff may restore a + * deferred cap. When the live tail uuid differs, a newer prompt owns the view. + */ + settledTranscriptTailUuid?: string | null +} + +export function canRestoreDeferredMaxTurnsCap( + handoff: ForegroundTurnBudgetHandoff, + currentMessages: readonly { uuid: string }[], +): boolean { + if (!handoff.deferredMaxTurnsCap) return false + const anchor = handoff.settledTranscriptTailUuid + if (anchor === undefined) return true + if (anchor === null) return currentMessages.length === 0 + return currentMessages.at(-1)?.uuid === anchor +} + +export function resolveReplMaxTurnsForSession( + maxTurns: number | undefined, + session: { + isRemoteSession: boolean + directConnectConfig: unknown + sshSession: unknown + }, +): number | undefined { + if (!isLocalInteractiveMaxTurnsSession(session)) { + return undefined + } + return resolveReplMaxTurns(maxTurns) } export function computeDeferredMaxTurnsCapForBackgroundHandoff( diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 285a94905d..12fd14fc77 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -5,11 +5,13 @@ import { Option, } from '@commander-js/extra-typings' import { + canRestoreDeferredMaxTurnsCap, claimBackgroundTurnBudget, computeDeferredMaxTurnsCapForBackgroundHandoff, createForegroundTurnBudgetHandoff, isLocalInteractiveMaxTurnsSession, releaseForegroundTurnBudget, + resolveReplMaxTurnsForSession, shouldContinueBackgroundAfterForegroundQuery, shouldShowReplMaxTurnsUnlimitedWarning, waitForForegroundTurnBudgetSettlement, @@ -324,6 +326,57 @@ describe('interactive REPL max-turn cap', () => { ).toBeUndefined() }) + test('skips deferred max-turn restoration when a newer prompt owns the transcript', () => { + const handoff = createForegroundTurnBudgetHandoff(1) + handoff.deferredMaxTurnsCap = { maxTurns: 1, turnCount: 2 } + handoff.settledTranscriptTailUuid = 'prior-tail' + + expect( + canRestoreDeferredMaxTurnsCap(handoff, [ + { uuid: 'prior-tail' }, + ]), + ).toBe(true) + expect( + canRestoreDeferredMaxTurnsCap(handoff, [ + { uuid: 'prior-tail' }, + { uuid: 'new-user-turn' }, + ]), + ).toBe(false) + }) + + test('does not apply the local interactive cap in remote-backed sessions', () => { + clearTurnEnv() + process.env.OPENCLAUDE_MAX_TURNS = '80' + expect( + resolveReplMaxTurnsForSession(undefined, { + isRemoteSession: true, + directConnectConfig: undefined, + sshSession: undefined, + }), + ).toBeUndefined() + expect( + resolveReplMaxTurnsForSession(90, { + isRemoteSession: false, + directConnectConfig: {}, + sshSession: undefined, + }), + ).toBeUndefined() + expect( + resolveReplMaxTurnsForSession(90, { + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: {}, + }), + ).toBeUndefined() + expect( + resolveReplMaxTurnsForSession(90, { + isRemoteSession: false, + directConnectConfig: undefined, + sshSession: undefined, + }), + ).toBe(90) + }) + test('does not continue a background handoff after the foreground query throws', () => { expect( shouldContinueBackgroundAfterForegroundQuery({ diff --git a/src/utils/replMaxTurns.ts b/src/utils/replMaxTurns.ts index aea836ab8c..e5cd97e741 100644 --- a/src/utils/replMaxTurns.ts +++ b/src/utils/replMaxTurns.ts @@ -14,7 +14,7 @@ export const REPL_MAX_TURNS_OPTIONS = [50, 100, 200, 500] as const * Keep remote-backed sessions explicitly out of scope in this string. */ export const MAX_TURNS_CLI_DESCRIPTION = - 'Maximum number of agentic turns per prompt. In local interactive mode, set to 0 for unlimited turns (use with caution). This overrides the default 50-turn REPL query cap and is also configurable via OPENCLAUDE_MAX_TURNS or /config. Remote-backed sessions (connect/ssh/--remote) are not capped by this flag. In --print mode this early-exits after the specified number of turns.' + 'Maximum number of agentic turns per prompt. In local interactive mode, set to 0 for unlimited turns (use with caution). This overrides the default 50-turn REPL query cap and is also configurable via OPENCLAUDE_MAX_TURNS or /config. Does not apply to remote-backed sessions (connect/ssh/--remote). In --print mode this early-exits after the specified number of turns.' export function parseMaxTurnsCli(value: string): number { const parsed = value.trim() === '' ? Number.NaN : Number(value) From 926b2e7d771119a6f55812f2397e92573dc25c7a Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 20:14:22 -0700 Subject: [PATCH 30/34] fix(repl): use messagesRef for deferred cap transcript anchor persistentMessages is block-scoped inside onQuery try; read the settled tail from messagesRef in finally so typecheck passes. --- src/screens/REPL.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index a602cb09f4..cbae2c8fcc 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -3408,7 +3408,7 @@ export function REPL({ if (deferredCap) { turnBudgetHandoff.deferredMaxTurnsCap = deferredCap; turnBudgetHandoff.settledTranscriptTailUuid = - persistentMessages.at(-1)?.uuid ?? null; + messagesRef.current.at(-1)?.uuid ?? null; } } releaseForegroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef, turnBudgetHandoff, shouldContinueBackground); From 351b6049815cad78f3ca0973f6e023f819b60731 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 20:33:16 -0700 Subject: [PATCH 31/34] test: harden context fallback warning assertion after max-turns tests Scope the unknown-model context test to [context] warnings only so unrelated import-time debug logs do not fail CI, and clear turn env vars in both that test and replMaxTurnsProp setup to avoid cross-file pollution. --- src/screens/replMaxTurnsProp.test.ts | 6 +++++- src/utils/context.test.ts | 11 +++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/screens/replMaxTurnsProp.test.ts b/src/screens/replMaxTurnsProp.test.ts index 12fd14fc77..5ae0baea8f 100644 --- a/src/screens/replMaxTurnsProp.test.ts +++ b/src/screens/replMaxTurnsProp.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test' import { Command as CommanderCommand, InvalidArgumentError, @@ -71,6 +71,10 @@ function createMaxTurnsCliProgram(): CommanderCommand { return program } +beforeEach(() => { + clearTurnEnv() +}) + afterEach(() => { for (const key of ENV_KEYS) { const previous = savedEnv[key] diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index cec4c1d110..e0207dc171 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -537,6 +537,8 @@ test('unknown openai-compatible model fallback logs one debug warning and no con process.env.CLAUDE_CODE_USE_OPENAI = '1' delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS delete process.env.OPENAI_MODEL + delete process.env.OPENCLAUDE_MAX_TURNS + delete process.env.CLAUDE_CODE_MAX_TURNS const actualDebugModule = await import('./debug.js') const logForDebugging = spyOn( @@ -559,8 +561,13 @@ test('unknown openai-compatible model fallback logs one debug warning and no con contextModule.getContextWindowForModel('another-unknown-3p-model'), ).toBe(128_000) expect(consoleError).not.toHaveBeenCalled() - expect(logForDebugging).toHaveBeenCalledTimes(1) - expect(logForDebugging.mock.calls[0]?.[1]).toEqual({ level: 'warn' }) + const contextWarnings = logForDebugging.mock.calls.filter( + ([message, options]) => + typeof message === 'string' && + message.startsWith('[context] Warning:') && + options?.level === 'warn', + ) + expect(contextWarnings).toHaveLength(1) } finally { console.error = originalConsoleError mock.restore() From b62b1b54db8f5d910bb402443342ed436c0a152e Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 20:45:04 -0700 Subject: [PATCH 32/34] test: address PR review findings on headless max-turns boundary Add a runHeadless-to-ask regression that asserts maxTurns 0 is forwarded through the headless print path, and restore OPENCLAUDE_MAX_TURNS env vars in context.test.ts after the unknown-model fallback test mutates them. --- src/cli/printMaxTurns.test.ts | 121 +++++++++++++++++++++++++++++++++- src/utils/context.test.ts | 12 ++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/cli/printMaxTurns.test.ts b/src/cli/printMaxTurns.test.ts index 45cd95dc5c..8e2d1155af 100644 --- a/src/cli/printMaxTurns.test.ts +++ b/src/cli/printMaxTurns.test.ts @@ -1,14 +1,20 @@ -import { describe, expect, spyOn, test } from 'bun:test' +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 { asSystemPrompt } from '../utils/systemPromptType.js' +import { getDefaultAppState, type AppState } from '../state/AppStateStore.js' +import type { Tools } from '../Tool.js' import { createAssistantMessage, createUserMessage, } from '../utils/messages.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 { @@ -72,7 +78,84 @@ function makeHeadlessQueryParams(maxTurns: number | undefined): QueryParams { } } +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>, +): Promise { + 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) @@ -95,4 +178,38 @@ describe('headless --print max-turns', () => { expect(terminal?.reason).toBe('completed') querySpy.mockRestore() }) + + test('forwards maxTurns 0 through runHeadless into ask()', async () => { + process.env.CLAUDE_CODE_SIMPLE = '1' + + 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) + } + + const runPromise = runHeadless( + 'headless prompt', + getAppState, + setAppState, + [] as Command[], + [] as Tools, + {}, + [], + createHeadlessRunOptions(0), + ).catch(() => {}) + + await waitForAskCall(askSpy) + + expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) + + askSpy.mockRestore() + await runPromise + }) }) diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index e0207dc171..1f5db9c481 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -32,6 +32,8 @@ const originalEnv = { LONGCAT_API_KEY: process.env.LONGCAT_API_KEY, CLAUDE_CODE_MAX_CONTEXT_TOKENS: process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS, USER_TYPE: process.env.USER_TYPE, + OPENCLAUDE_MAX_TURNS: process.env.OPENCLAUDE_MAX_TURNS, + CLAUDE_CODE_MAX_TURNS: process.env.CLAUDE_CODE_MAX_TURNS, } beforeEach(async () => { @@ -142,6 +144,16 @@ afterEach(() => { } else { process.env.USER_TYPE = originalEnv.USER_TYPE } + if (originalEnv.OPENCLAUDE_MAX_TURNS === undefined) { + delete process.env.OPENCLAUDE_MAX_TURNS + } else { + process.env.OPENCLAUDE_MAX_TURNS = originalEnv.OPENCLAUDE_MAX_TURNS + } + if (originalEnv.CLAUDE_CODE_MAX_TURNS === undefined) { + delete process.env.CLAUDE_CODE_MAX_TURNS + } else { + process.env.CLAUDE_CODE_MAX_TURNS = originalEnv.CLAUDE_CODE_MAX_TURNS + } } finally { clearSessionContextWindowOverride() releaseSharedMutationLock() From bb9a1b15259cf28f6051c7d8c0b412d9a0a6008a Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 20:50:48 -0700 Subject: [PATCH 33/34] test: tidy headless max-turns boundary test and env isolation Mock headless stdout so runHeadless completes cleanly without leaking output, restore spies in finally, and centralize turn-env cleanup in context.test beforeEach. --- src/cli/printMaxTurns.test.ts | 40 ++++++++++++++++++++--------------- src/utils/context.test.ts | 4 ++-- 2 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/cli/printMaxTurns.test.ts b/src/cli/printMaxTurns.test.ts index 8e2d1155af..7005b47ec9 100644 --- a/src/cli/printMaxTurns.test.ts +++ b/src/cli/printMaxTurns.test.ts @@ -12,6 +12,7 @@ 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' @@ -182,6 +183,9 @@ describe('headless --print max-turns', () => { 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() @@ -194,22 +198,24 @@ describe('headless --print max-turns', () => { state = update(state) } - const runPromise = runHeadless( - 'headless prompt', - getAppState, - setAppState, - [] as Command[], - [] as Tools, - {}, - [], - createHeadlessRunOptions(0), - ).catch(() => {}) - - await waitForAskCall(askSpy) - - expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) - - askSpy.mockRestore() - await runPromise + try { + const 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 { + askSpy.mockRestore() + stdoutSpy.mockRestore() + } }) }) diff --git a/src/utils/context.test.ts b/src/utils/context.test.ts index 1f5db9c481..edf3c13092 100644 --- a/src/utils/context.test.ts +++ b/src/utils/context.test.ts @@ -55,6 +55,8 @@ beforeEach(async () => { delete process.env.LONGCAT_API_KEY delete process.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS delete process.env.USER_TYPE + delete process.env.OPENCLAUDE_MAX_TURNS + delete process.env.CLAUDE_CODE_MAX_TURNS }) afterEach(() => { @@ -549,8 +551,6 @@ test('unknown openai-compatible model fallback logs one debug warning and no con process.env.CLAUDE_CODE_USE_OPENAI = '1' delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS delete process.env.OPENAI_MODEL - delete process.env.OPENCLAUDE_MAX_TURNS - delete process.env.CLAUDE_CODE_MAX_TURNS const actualDebugModule = await import('./debug.js') const logForDebugging = spyOn( From b1ac0c8ecc51b86bf94512f2ca8aa4ace199c3a8 Mon Sep 17 00:00:00 2001 From: jatmn Date: Wed, 5 Aug 2026 21:14:28 -0700 Subject: [PATCH 34/34] fix: address PR review findings for max-turns background handoff Separate model-request lifecycle from provider dispatch acceptance so interruption correction arms before async prep, notification ownership commits only after dispatch, deferred turn caps restore on every abort path, and foreground work stays blocked while handoff preparation runs. --- src/cli/printMaxTurns.test.ts | 4 ++- src/query.ts | 24 +++++++------- src/screens/REPL.tsx | 46 ++++++++++++++++++++------ src/tasks/LocalMainSessionTask.test.ts | 4 +-- src/tasks/LocalMainSessionTask.ts | 18 +++++----- src/utils/taskNotificationIdentity.ts | 1 + 6 files changed, 63 insertions(+), 34 deletions(-) diff --git a/src/cli/printMaxTurns.test.ts b/src/cli/printMaxTurns.test.ts index 7005b47ec9..2153661918 100644 --- a/src/cli/printMaxTurns.test.ts +++ b/src/cli/printMaxTurns.test.ts @@ -198,8 +198,9 @@ describe('headless --print max-turns', () => { state = update(state) } + let runPromise: Promise | undefined try { - const runPromise = runHeadless( + runPromise = runHeadless( 'headless prompt', getAppState, setAppState, @@ -214,6 +215,7 @@ describe('headless --print max-turns', () => { expect(askSpy.mock.calls[0]?.[0]?.maxTurns).toBe(0) await runPromise } finally { + await runPromise?.catch(() => {}) askSpy.mockRestore() stdoutSpy.mockRestore() } diff --git a/src/query.ts b/src/query.ts index c0e588c3ab..8dc317f78e 100644 --- a/src/query.ts +++ b/src/query.ts @@ -501,6 +501,8 @@ export type QueryParams = { /** Called around each outbound model request, including retries. */ onModelRequestStart?: () => void onModelRequestEnd?: () => void + /** Called once provider dispatch is accepted for the current attempt. */ + onProviderDispatchAccepted?: () => void systemPrompt: SystemPrompt userContext: { [k: string]: string } systemContext: { [k: string]: string } @@ -1491,8 +1493,13 @@ async function* queryLoop( // Claim the turn from its callback immediately before the actual // request is dispatched, not merely when callModel is entered. let providerDispatchRejected = false - let providerRequestStarted = false + let providerDispatchAccepted = false + let modelRequestLifecycleStarted = false try { + // Arm interruption correction and other per-attempt hooks before + // callModel performs async provider preparation. + params.onModelRequestStart?.() + modelRequestLifecycleStarted = true for await (const message of deps.callModel({ messages: prependUserContext( injectRequestOnlyMessages( @@ -1543,7 +1550,7 @@ async function* queryLoop( // Retries reuse this turn's reservation, but they must still // prove the foreground owns dispatch after any asynchronous // credential refresh or client recreation. - if (providerRequestStarted) return true + if (providerDispatchAccepted) return true if ( params.turnBudget && params.turnBudget.turnsStarted >= turnCount && @@ -1552,15 +1559,6 @@ async function* queryLoop( providerDispatchRejected = true return false } - params.onModelRequestStart?.() - // The matching end callback belongs to the lifecycle start, - // even when that callback aborts before provider dispatch. - providerRequestStarted = true - // The lifecycle callback is synchronous but may itself abort. - if (toolUseContext.abortController.signal.aborted) { - providerDispatchRejected = true - return false - } // Fallback attempts reuse turnCount. The local reservation // makes retries idempotent while rejecting a stale claimant. if ( @@ -1570,6 +1568,8 @@ async function* queryLoop( params.turnBudget.turnsStarted = turnCount reservedTurnCount = turnCount } + providerDispatchAccepted = true + params.onProviderDispatchAccepted?.() return true }, // Explicit /effort selection wins. When it is unset, carry the @@ -1793,7 +1793,7 @@ async function* queryLoop( return { reason: 'aborted_streaming' } } } finally { - if (providerRequestStarted) params.onModelRequestEnd?.() + if (modelRequestLifecycleStarted) params.onModelRequestEnd?.() } queryCheckpoint('query_api_streaming_end') diff --git a/src/screens/REPL.tsx b/src/screens/REPL.tsx index cbae2c8fcc..a7afdd9348 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -666,6 +666,7 @@ export function REPL({ const isRemoteSession = !!remoteSessionConfig; const foregroundTurnBudgetRef = useRef(null); const backgroundHandoffStartedRef = useRef(false); + const [backgroundHandoffPreparing, setBackgroundHandoffPreparing] = useState(false); useEffect(() => { if ( @@ -1059,7 +1060,7 @@ export function REPL({ // Derived: any loading source active. Read-only — no setter. Local query // loading is driven by queryGuard (reserve/tryStart/end/cancelReservation), // external loading by setIsExternalLoading. - const isLoading = isQueryActive || isExternalLoading; + const isLoading = isQueryActive || isExternalLoading || backgroundHandoffPreparing; // Elapsed time is computed by SpinnerWithVerb from these refs on each // animation frame, avoiding a useInterval that re-renders the entire REPL. @@ -2855,20 +2856,32 @@ export function REPL({ // Session backgrounding (Ctrl+B to background/foreground) const handleBackgroundQuery = useCallback(() => { const backgroundSessionId = getSessionId(); + const backgroundSessionTitle = terminalTitle; + setBackgroundHandoffPreparing(true); // Transfer the exact per-prompt budget before aborting. Re-resolving the // limit or copying a callback-maintained count here can reset or skew the // cap while the foreground query is winding down. const backgroundHandoff = claimBackgroundTurnBudget(foregroundTurnBudgetRef, backgroundHandoffStartedRef); if (!backgroundHandoff) return; const restoreDeferredMaxTurnsCap = () => { - const cap = backgroundHandoff.deferredMaxTurnsCap; - if (!cap || !canRestoreDeferredMaxTurnsCap(backgroundHandoff, messagesRef.current)) return; - backgroundHandoff.deferredMaxTurnsCap = undefined; - setMessages(prev => [...prev, createAttachmentMessage({ - type: 'max_turns_reached', - maxTurns: cap.maxTurns, - turnCount: cap.turnCount - })]); + const attemptRestore = (): boolean => { + const cap = backgroundHandoff.deferredMaxTurnsCap; + if (!cap || !canRestoreDeferredMaxTurnsCap(backgroundHandoff, messagesRef.current)) { + return false; + } + backgroundHandoff.deferredMaxTurnsCap = undefined; + setMessages(prev => [...prev, createAttachmentMessage({ + type: 'max_turns_reached', + maxTurns: cap.maxTurns, + turnCount: cap.turnCount + })]); + return true; + }; + if (!attemptRestore()) { + void backgroundHandoff.settled.then(() => { + attemptRestore(); + }); + } }; const backgroundSession = startBackgroundSession({ prepare: async backgroundAbortController => { @@ -2969,7 +2982,7 @@ export function REPL({ throw error; } }, - description: terminalTitle, + description: backgroundSessionTitle, setAppState, agentDefinition: mainThreadAgentDefinition, onPreparationError: () => { @@ -2987,6 +3000,7 @@ export function REPL({ ); }, onSettled: controller => { + setBackgroundHandoffPreparing(false); setAbortController(current => current === controller ? null : current, ); @@ -3287,6 +3301,16 @@ export function REPL({ // Returns null if already running — no separate check-then-set. const lifecycleTracker = queryLifecycleTrackerRef.current; const querySource = getQuerySourceForREPL(); + if (backgroundHandoffPreparing) { + logEvent('tengu_concurrent_onquery_detected', {}); + newMessages.filter((m): m is UserMessage => m.type === 'user' && !m.isMeta).map(_ => getContentText(_.message.content)).filter(_ => _ !== null).forEach((msg, i) => { + enqueue(buildConcurrentRequeuedPrompt(msg, isInterruptionCorrectionEligible)); + if (i === 0) { + logEvent('tengu_concurrent_onquery_enqueued', {}); + } + }); + return false; + } const startResult = queryGuard.tryStart({ queryId: randomUUID(), querySource, @@ -3572,7 +3596,7 @@ export function REPL({ } } } - }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, maxTurnsProp, isRemoteSession, directConnectConfig, sshSession]); + }, [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete, maxTurnsProp, isRemoteSession, directConnectConfig, sshSession, backgroundHandoffPreparing]); // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message diff --git a/src/tasks/LocalMainSessionTask.test.ts b/src/tasks/LocalMainSessionTask.test.ts index 18b4d831ff..7558720975 100644 --- a/src/tasks/LocalMainSessionTask.test.ts +++ b/src/tasks/LocalMainSessionTask.test.ts @@ -215,7 +215,7 @@ describe('LocalMainSessionTask', () => { queryImpl: (async function* ( params, ): AsyncGenerator { - params.onModelRequestStart?.() + params.onProviderDispatchAccepted?.() await queryBlocked yield createAssistantMessage({ content: 'late' }) return { reason: 'completed' } @@ -269,7 +269,7 @@ describe('LocalMainSessionTask', () => { ) expect(restoreCalls).toBe(0) - expect(commitCalls).toBe(1) + expect(commitCalls).toBe(0) }) test('retains a max-turn terminal attachment in task messages', async () => { diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 9c5bf96b82..0c87ab76d2 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -394,6 +394,7 @@ export function startBackgroundSession({ const finishAbortedTask = (): void => { restorePreDispatchNotifications() + onContinuationCancelled?.() // chat:killAgents already marks the task notified and emits this event. // stopTask kills it without doing either for local-agent tasks, so close // the SDK lifecycle exactly once no matter which phase observed abort. @@ -488,12 +489,9 @@ export function startBackgroundSession({ for await (const event of queryImpl({ messages: bgMessages, ...queryParams, - onModelRequestStart: () => { - queryParams.onModelRequestStart?.() - if (!abortSignal.aborted) { - providerRequestStarted = true - commitNotificationOwnership?.() - } + onProviderDispatchAccepted: () => { + providerRequestStarted = true + commitNotificationOwnership?.() }, })) { if (abortSignal.aborted) { @@ -577,7 +575,9 @@ export function startBackgroundSession({ finishAbortedTask() return } - commitNotificationOwnership?.() + if (providerRequestStarted) { + commitNotificationOwnership?.() + } completeMainSessionTask(taskId, true, setAppState) } catch (error) { if (abortSignal.aborted) { @@ -591,7 +591,9 @@ export function startBackgroundSession({ } logError(error) if (taskRegistered) { - commitNotificationOwnership?.() + if (providerRequestStarted) { + commitNotificationOwnership?.() + } completeMainSessionTask(taskId, false, setAppState) } else { restorePreDispatchNotifications() diff --git a/src/utils/taskNotificationIdentity.ts b/src/utils/taskNotificationIdentity.ts index 157d0c8914..a6bd00e478 100644 --- a/src/utils/taskNotificationIdentity.ts +++ b/src/utils/taskNotificationIdentity.ts @@ -78,6 +78,7 @@ export function pendingCommandsForEmbeddedNotifications( if ( message.type === 'attachment' && message.attachment.type === 'queued_command' && + message.attachment.commandMode === 'task-notification' && typeof message.attachment.prompt === 'string' ) { embeddedKeys.add(getTaskNotificationDedupKey(message.attachment.prompt))