Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
be4fd80
fix(native-chat): honor saved agent launch commands and arguments
brennanb2025 Oct 6, 2026
af06098
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
cd4d7c3
fix(test): retain provider import after syncing main
brennanb2025 Oct 6, 2026
294c402
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
54a1b35
fix(native-chat): preserve launch arguments and explain invalid settings
brennanb2025 Oct 6, 2026
2d4965b
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
e022bfd
refactor(native-chat): require the current launch arguments source
brennanb2025 Oct 6, 2026
16894d2
fix(native-chat): require saved arguments when installing the runtime
brennanb2025 Oct 6, 2026
7216560
fix(native-chat): show saved argument refusals beside launch retry
brennanb2025 Oct 6, 2026
a408549
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
669afea
fix: ship saved-argument error messages in every locale
brennanb2025 Oct 6, 2026
ba02934
test: align model discovery with executable overrides
brennanb2025 Oct 6, 2026
521a831
Merge main to resolve structured launch integration conflicts
brennanb2025 Oct 6, 2026
038b769
Merge current main and keep saved launch options in structured chat
brennanb2025 Oct 6, 2026
3f70f4e
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
dd84284
chore: drop the local pnpm install lockfile block
brennanb2025 Oct 6, 2026
5753815
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
6c3b2a4
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
7aac4f9
Merge remote-tracking branch 'origin/main' into brennanb2025/experime…
brennanb2025 Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions src/main/agent-launch/agent-launch-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,10 +504,14 @@ describe('caller-supplied launch inputs', () => {
expect(h.createStructuredSession).not.toHaveBeenCalled()
})

// A custom launch command applies to terminal launches only; native chat ignores it.
// Command values never change the selected chat surface.
it.each([
['claude', 'claude-wrapper'],
['codex', 'codex-nightly']
['codex', 'codex-nightly'],
['claude', 'npx claude'],
['codex', 'wrapper --arg'],
['claude', '/missing/claude'],
['codex', './codex']
] as const)('opens a structured %s session despite launch command %s', async (agent, command) => {
const h = harness({
settings: { ...STRUCTURED_PREFERENCE, agentCmdOverrides: { [agent]: command } }
Expand Down Expand Up @@ -587,7 +591,7 @@ describe('caller-supplied launch inputs', () => {
const result = await h.run({ agent: 'claude', target: EXISTING, agentArgs: '--model opus' })

expect(result.outcome.kind).toBe('structured')
expect(result.warning).toContain('does not apply launch arguments')
expect(result.warning).toContain('per-launch argument override was ignored')
})

it('warns when a structured session ignored an explicit "no arguments" too', async () => {
Expand All @@ -597,7 +601,7 @@ describe('caller-supplied launch inputs', () => {
// The structured path reads the bypass-permissions bit from the user's SETTINGS default, so a
// caller that asked for no arguments can still get a session with more permission than it asked
// for. Staying silent about that is the failure mode worth a test.
expect(result.warning).toContain('does not apply launch arguments')
expect(result.warning).toContain('per-launch argument override was ignored')
})

it('leaves a structured launch unwarned when it carried no arguments at all', async () => {
Expand Down
13 changes: 2 additions & 11 deletions src/main/agent-launch/agent-launch-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,16 +292,7 @@ async function createSurface(
}

/**
* A structured session cannot apply launch arguments, so a launch that carried some and got one
* anyway has to say so.
*
* Reported rather than routed around: the arguments field is a TUI concern by an explicit decision
* (the Agent SDK and app-server version their option sets independently of the interactive CLI's,
* and the launch command names the CLI binary, so both apply to terminal launches only), so
* downgrading here would override a stated user preference on the strength of a field that is not
* evidence about the surface. `null` warns too: "no arguments" is also unapplied, and the structured
* path still reads the bypass-permissions bit out of the user's *settings* default, so a caller that
* asked for none can get a session running with more permission than it requested.
* Structured chat uses saved Arguments, so a per-call override still needs a truthful warning.
*/
function ignoredStructuredAgentArgsWarning(
intent: AgentLaunchIntent
Expand All @@ -310,7 +301,7 @@ function ignoredStructuredAgentArgsWarning(
? undefined
: {
warning:
'Started a structured chat session, which does not apply launch arguments; the requested arguments were ignored.'
'Started a structured chat session using saved agent Arguments; the per-launch argument override was ignored.'
}
}

Expand Down
26 changes: 22 additions & 4 deletions src/main/claude/claude-agent-sdk-contract-pins.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ function resolvedLaunch(permissionMode: PermissionMode, launchArgs: string[] = [
launchArgs
} as unknown as AgentSessionRecord
return createClaudeStructuredLaunchResolver({
resolveLaunchArgs: () => launchArgs,
store: { getRecord: () => record, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async () => '/repos/workspace-1',
resolveCommand: () => FAKE_CLI,
Expand Down Expand Up @@ -329,7 +330,18 @@ describe('Claude Agent SDK contract pins', () => {
const spawns: SpawnSeen[] = []
// Driven by the real resolver, so the argv walk covers its option set and merge order,
// not a hand-written options literal.
const launch = await resolvedLaunch('bypassPermissions', ['--model', 'claude-sonnet-4-5'])
const launch = await resolvedLaunch('bypassPermissions', [
'--model',
'claude-sonnet-4-5',
'--dangerously-skip-permissions',
'--output-format',
'text',
'--session-id=wrong-session',
'--add-dir',
'/repo/one',
'/repo/two',
'--add-dir=/repo/three'
])
await drainQuery({
...launch.options,
pathToClaudeCodeExecutable: FAKE_CLI,
Expand All @@ -346,9 +358,15 @@ describe('Claude Agent SDK contract pins', () => {
expect(argv.filter((arg) => arg === '--dangerously-skip-permissions')).toHaveLength(1)
expect(argv).not.toContain('--allow-dangerously-skip-permissions')
expect(argv[argv.indexOf('--permission-mode') + 1]).toBe('default')
// Configured CLI arguments are a terminal concern; a record written before they stopped
// being read must not smuggle one back into the child's argv.
expect(argv).not.toContain('--model')
expect(argv[argv.indexOf('--model') + 1]).toBe('claude-sonnet-4-5')
expect(argv.flatMap((arg, index) => (arg === '--add-dir' ? [argv[index + 1]] : []))).toEqual([
'/repo/one',
'/repo/two',
'/repo/three'
])
expect(argv.filter((arg) => arg === '--model')).toHaveLength(1)
expect(argv.filter((arg) => arg === '--output-format')).toHaveLength(1)
expect(argv[argv.indexOf('--output-format') + 1]).toBe('stream-json')
// Headless print mode is the SDK's only mode; `query()` never passes `-p`,
// and if the SDK ever started passing it this pin would notice.
const impliedByHeadlessQuery = new Set(['-p'])
Expand Down
105 changes: 56 additions & 49 deletions src/main/claude/claude-model-catalog-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PROVIDER_STDIN_END_GRACE_MS } from '../provider-process/provider-proces
import { createClaudeModelCatalogProbe } from './claude-model-catalog-probe'
import { resolveClaudeStructuredInvocation } from './claude-structured-launch-resolution'
import type { discoverModelsLocal } from '../text-generation/commit-message-model-discovery'
import { resolveStructuredAgentCommand } from '../native-chat/structured-agent-command-resolution'
import type {
SpawnedSourceControlAgentProcess,
SpawnSourceControlAgent
Expand All @@ -16,14 +17,17 @@ type DiscoverResult = Awaited<ReturnType<typeof discoverModelsLocal>>

const AUTH_POLICY = { stripAuthEnv: false } as const

function probeDeps(): {
function probeDeps(command = `"${process.execPath}"`): {
resolveCommand: () => string
resolveEnv: () => Record<string, string>
resolveInheritedEnv: () => Promise<Record<string, string>>
resolveAuthPolicy: () => typeof AUTH_POLICY
} {
return {
resolveCommand: () => '/resolved/claude with spaces/claude',
resolveCommand: () =>
resolveStructuredAgentCommand('claude', {
agentCmdOverrides: { claude: command }
}),
resolveEnv: () => ({ ANTHROPIC_MODEL_GATEWAY: 'https://gateway.example' }),
resolveInheritedEnv: async () => ({ PATH: '/resolved/bin', HOME: '/homes/user' }),
resolveAuthPolicy: () => AUTH_POLICY
Expand Down Expand Up @@ -55,53 +59,56 @@ function listedResult(): DiscoverResult {
}

describe('claude model catalog probe', () => {
it('lists under the same resolved command and env as a structured session launch', async () => {
const deps = probeDeps()
const captured: DiscoverInput[] = []
const spawnAgent = vi.fn<SpawnSourceControlAgent>(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child.
() => ({}) as SpawnedSourceControlAgentProcess
)
const probe = createClaudeModelCatalogProbe({
...deps,
spawnAgent,
discover: async (input) => {
captured.push(input)
// Drive the probe's spawn wrapper once, the way the real listing would.
input.spawnAgent({
binary: 'claude',
args: ['--list'],
env: input.env,
stdinMode: 'ignore',
useCwdForNative: false
})
return listedResult()
}
})
const success = await probe('/homes/account-a')
expect(success.origin).toBe('probe')
expect(success.models).toEqual([
{
id: 'sonnet',
label: 'Sonnet',
isDefault: true,
// The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort.
efforts: [{ value: 'low', label: 'Low' }]
}
])
// The probe's env is exactly the session launch's resolved env for the
// same deps, plus the account pin — and the spawn runs the resolved
// binary, never the bare spec name.
const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({
...env,
CLAUDE_CONFIG_DIR: '/homes/account-a'
}))
expect(captured).toHaveLength(1)
expect(captured[0]!.env).toEqual(invocation.env)
expect(captured[0]!.agentCommandOverride).toBeUndefined()
expect(spawnAgent).toHaveBeenCalledTimes(1)
expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command)
})
it.each([`"${process.execPath}"`, 'npx claude', '/missing/claude', './claude'])(
'lists with the session executable for Command %s',
async (command) => {
const deps = probeDeps(command)
const captured: DiscoverInput[] = []
const spawnAgent = vi.fn<SpawnSourceControlAgent>(
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fake discover below never reads the returned child.
() => ({}) as SpawnedSourceControlAgentProcess
)
const probe = createClaudeModelCatalogProbe({
...deps,
spawnAgent,
discover: async (input) => {
captured.push(input)
// Drive the probe's spawn wrapper once, the way the real listing would.
input.spawnAgent({
binary: 'claude',
args: ['--list'],
env: input.env,
stdinMode: 'ignore',
useCwdForNative: false
})
return listedResult()
}
})
const success = await probe('/homes/account-a')
expect(success.origin).toBe('probe')
expect(success.models).toEqual([
{
id: 'sonnet',
label: 'Sonnet',
isDefault: true,
// The spec's `defaultThinkingLevel` is not what Claude runs; naming it would label the effort.
efforts: [{ value: 'low', label: 'Low' }]
}
])
// The probe's env is exactly the session launch's resolved env for the
// same deps, plus the account pin — and the spawn runs the resolved
// binary, never the bare spec name.
const invocation = await resolveClaudeStructuredInvocation(deps, (env) => ({
...env,
CLAUDE_CONFIG_DIR: '/homes/account-a'
}))
expect(captured).toHaveLength(1)
expect(captured[0]!.env).toEqual(invocation.env)
expect(captured[0]!.agentCommandOverride).toBeUndefined()
expect(spawnAgent).toHaveBeenCalledTimes(1)
expect(spawnAgent.mock.calls[0]![0].binary).toBe(invocation.command)
}
)

it('pins no CLAUDE_CONFIG_DIR for the CLI default home, exactly as a session spawn does', async () => {
const envs: DiscoverInput['env'][] = []
Expand Down
2 changes: 2 additions & 0 deletions src/main/claude/claude-structured-auth-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ function resolverFor(options: {
authSwitchSettleTimeoutMs?: number
}): ReturnType<typeof createClaudeStructuredLaunchResolver> {
return createClaudeStructuredLaunchResolver({
resolveLaunchArgs: () => [],
store: { getRecord: () => record(), pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude',
Expand All @@ -67,6 +68,7 @@ function realResolverAdapter(
} as unknown as AgentSessionRecord
return new ClaudeStructuredSessionAdapter({
resolveLaunch: createClaudeStructuredLaunchResolver({
resolveLaunchArgs: () => [],
store: { getRecord: () => resumable, pinLaunchDirectory: vi.fn() },
resolveWorkspacePath: async (id) => `/repos/${id}`,
resolveCommand: () => '/usr/local/bin/claude',
Expand Down
90 changes: 90 additions & 0 deletions src/main/claude/claude-structured-launch-args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest'
import { claudeStructuredLaunchArgs } from './claude-structured-launch-args'

describe('Claude structured launch arguments', () => {
it('translates long flags, equals values, and the model short flag', () => {
expect(
claudeStructuredLaunchArgs([
'-m',
'opus',
'--effort=high',
'--chrome',
'--add-dir',
'/repo/other'
])
).toEqual({
extraArgs: { model: 'opus', effort: 'high', chrome: null },
additionalDirectories: ['/repo/other']
})
})

it('removes SDK transport, permission, and lifecycle flags with their values', () => {
expect(
claudeStructuredLaunchArgs([
'-p',
'--input-format',
'text',
'--output-format=json',
'--json-schema',
'{}',
'--verbose',
'-r',
'other-session',
'-c',
'--session-id=other-session',
'--fork-session',
'--permission-mode',
'bypassPermissions',
'--dangerously-skip-permissions',
'--allow-dangerously-skip-permissions',
'--permission-prompt-tool',
'other',
'--replay-user-messages',
'--include-partial-messages',
'--setting-sources',
'none',
'--system-prompt',
'replacement',
'--append-system-prompt-file=other.txt',
'--model',
'opus'
])
).toEqual({ extraArgs: { model: 'opus' }, additionalDirectories: [] })
})

it('ignores positional prompts and tokens after --', () => {
expect(
claudeStructuredLaunchArgs(['prompt', '--model', 'opus', '--', '--effort', 'high'])
).toEqual({
extraArgs: { model: 'opus' },
additionalDirectories: []
})
})
it('keeps repeated and multi-value directory options in order', () => {
expect(
claudeStructuredLaunchArgs([
'--add-dir',
'/one',
'/two',
'--model',
'opus',
'--add-dir=/three',
'/four'
])
).toEqual({
extraArgs: { model: 'opus' },
additionalDirectories: ['/one', '/two', '/three', '/four']
})
})

it.each([
['--model', 'opus', '-m', 'sonnet'],
['--model', 'opus', 'sonnet'],
['--effort=high', 'medium'],
['--chrome', '--chrome'],
['--add-dir'],
['--add-dir=']
])('refuses options the SDK cannot preserve: %j', (...args) => {
expect(() => claudeStructuredLaunchArgs(args)).toThrow(/Arguments/)
})
})
Loading
Loading