Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,110 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { AgentSessionModelCatalogResult } from '../../../shared/agent-session-wire'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'

function record(provider: string, saved: Record<string, string> | undefined): AgentSessionRecord {
function launchPermission(provider: string, saved: Record<string, string> | undefined) {
const { permissionMode, ...options } = saved ?? {}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode.
return { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord
}

const listing = (
models: { id: string; isDefault?: boolean; supportsAutoMode?: boolean }[]
): AgentSessionModelCatalogResult => ({
origin: 'probe',
fetchedAt: 1,
models: models.map((model) => ({
label: model.id,
efforts: [],
isDefault: false,
...model
}))
})

function launchPermission(
provider: string,
options: Record<string, string> | undefined,
answer: AgentSessionModelCatalogResult | Error = { origin: 'unknown' }
) {
const read = vi.fn(async () => {
if (answer instanceof Error) {
throw answer
}
return answer
})
const modelCatalog = {
peek: () => null,
read,
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
}
return {
read,
result: structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog },
record(provider, options)
)
}
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's agent and mode.
const record = { sessionId: 'session-1', provider, options, permissionMode } as AgentSessionRecord
return structuredAgentSessionLaunchPermission({ agents: claudeAndCodexDeclared() }, record)
}

describe('the permission mode a chat starts with', () => {
it('launches a saved mode that needs no model as it is, without reading the catalog', async () => {
const claude = launchPermission('claude', { permissionMode: 'accept-edits' })
await expect(claude.result).resolves.toBe('accept-edits')
expect(claude.read).not.toHaveBeenCalled()
const codex = launchPermission('codex', { permissionMode: 'auto' })
await expect(codex.result).resolves.toBe('auto')
expect(codex.read).not.toHaveBeenCalled()
})

it('asks for a chat saved before modes existed', async () => {
await expect(launchPermission('claude', undefined).result).resolves.toBe('ask')
})

it('runs Claude auto on a model the saved catalog lists as supporting it', async () => {
const supported = launchPermission(
'claude',
{ permissionMode: 'auto', model: 'opus' },
listing([{ id: 'opus', supportsAutoMode: true }])
it('launches the saved mode as it is', async () => {
await expect(launchPermission('claude', { permissionMode: 'accept-edits' })).resolves.toBe(
'accept-edits'
)
await expect(supported.result).resolves.toBe('auto')
expect(supported.read).toHaveBeenCalledWith(expect.objectContaining({ savedOnly: true }))
await expect(launchPermission('codex', { permissionMode: 'auto' })).resolves.toBe('auto')
})

it('matches a chat with no model to the listed default', async () => {
// #30032: a resumed chat saved as auto must not relaunch as Ask when no model list is at hand.
it('launches Claude auto without consulting a model list', async () => {
await expect(
launchPermission(
'claude',
{ permissionMode: 'auto' },
listing([
{ id: 'sonnet', isDefault: true, supportsAutoMode: false },
{ id: 'opus', supportsAutoMode: true }
])
).result
).resolves.toBe('ask')
launchPermission('claude', { permissionMode: 'auto', model: 'opus' })
).resolves.toBe('auto')
})

it('asks when the catalog cannot confirm the model runs auto', async () => {
for (const answer of [
{ origin: 'unknown' } as const,
listing([{ id: 'opus' }]),
listing([{ id: 'sonnet', supportsAutoMode: true }]),
new Error('catalog unavailable')
]) {
await expect(
launchPermission('claude', { permissionMode: 'auto', model: 'opus' }, answer).result
).resolves.toBe('ask')
}
it('asks for a chat saved before modes existed, or with a mode its agent lacks', async () => {
await expect(launchPermission('claude', undefined)).resolves.toBe('ask')
await expect(launchPermission('codex', { permissionMode: 'accept-edits' })).resolves.toBe('ask')
})

it('has none for an agent with no modes', async () => {
await expect(launchPermission('pi', { permissionMode: 'full-access' }).result).resolves.toBe(
null
)
await expect(launchPermission('pi', { permissionMode: 'full-access' })).resolves.toBe(null)
})
})
Original file line number Diff line number Diff line change
@@ -1,46 +1,19 @@
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import {
agentChatPermissionModes,
chatModelSupportsAutoMode,
chatPermissionModeNeedsModelSupport,
effectiveChatPermissionMode,
savedChatPermissionMode,
type AgentChatPermissionMode
} from '../../../shared/agent-chat-permission-mode'
import type { StructuredAgentSessionHostDeps } from './structured-agent-session-host-types'

/** Use only the saved, folder-scoped catalog so startup never waits on a listing. */
async function listedAutoModeSupport(
catalog: StructuredAgentSessionHostDeps['modelCatalog'],
record: AgentSessionRecord
): Promise<boolean> {
const listing = await catalog
?.read({
agent: record.provider,
sessionId: record.sessionId,
savedOnly: true,
inSessionWorkspace: true
})
.catch(() => null)
return (
listing !== null &&
listing !== undefined &&
listing.origin !== 'unknown' &&
chatModelSupportsAutoMode(listing.models, record.options?.model)
)
}

/** null for unregistered agents or agents without permission modes. */
/** The saved choice, null without modes; Claude itself drops auto on models that can't run it. */
export async function structuredAgentSessionLaunchPermission(
deps: Pick<StructuredAgentSessionHostDeps, 'agents' | 'modelCatalog'>,
deps: Pick<StructuredAgentSessionHostDeps, 'agents'>,
record: AgentSessionRecord
): Promise<AgentChatPermissionMode | null> {
const support = agentChatPermissionModes(record.provider)
if (!support || !deps.agents.definition(record.provider)) {
return null
}
const intent = record.permissionMode
const autoSupported =
chatPermissionModeNeedsModelSupport(support, intent) &&
(await listedAutoModeSupport(deps.modelCatalog, record))
return effectiveChatPermissionMode(support, intent, autoSupported)
return savedChatPermissionMode(support, record.permissionMode)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import {
retireOutgrownStructuredAgentSessionChild,
structuredAgentSessionChildReportedSignedOut
} from './structured-agent-session-outgrown-child'
import { claudeAndCodexDeclared } from './structured-agent-session-adapter-router-test-support'
import { structuredAgentSessionLaunchPermission } from './structured-agent-session-launch-permission'
import type { AgentChatPermissionMode } from '../../../shared/agent-chat-permission-mode'
import type { AgentSessionRecord } from '../../../shared/agent-session-record'
import type { StructuredAgentSessionProviderChild } from './structured-agent-session-host-types'
import type {
AgentJournalItemBody,
Expand Down Expand Up @@ -278,87 +275,6 @@ describe('retireOutgrownStructuredAgentSessionChild', () => {
).not.toHaveBeenCalled()
})

describe('against the mode a start launches with', () => {
// Model Claude’s saved catalog: resolved default alias, auto on every model except haiku.
const listed = [
{ id: 'opus[1m]', isDefault: true, supportsAutoMode: true },
{ id: 'sonnet', isDefault: false, supportsAutoMode: true },
{ id: 'haiku', isDefault: false }
]
const catalog = (rows: typeof listed) => ({
peek: () => null,
read: vi.fn(async () => ({
origin: 'probe' as const,
fetchedAt: 1,
models: rows.map((row) => ({ ...row, label: row.id, efforts: [] }))
})),
retainedLiveModelIds: vi.fn(async () => []),
recordLiveListing: vi.fn(),
prewarm: vi.fn(async () => undefined),
stop: vi.fn(),
providerStarted: vi.fn()
})
const launch = ({ permissionMode, ...options }: Record<string, string>, rows = listed) => {
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the launch mode reads only the record's id, agent, options and mode.
const record = {
sessionId: 'session',
provider: 'claude',
options,
permissionMode
} as AgentSessionRecord
return () =>
structuredAgentSessionLaunchPermission(
{ agents: claudeAndCodexDeclared(), modelCatalog: catalog(rows) },
record
)
}
const stoppedAfterLaunch = async (
launched: AgentChatPermissionMode,
current: () => Promise<AgentChatPermissionMode | null>
) =>
(await retire({ child: childAt({ permissionMode: launched }) }, {}, undefined, current))
.stopAgent.mock.calls.length > 0

it('restarts Full access or Accept edits into Ask when auto is not confirmed', async () => {
const unconfirmed = [
launch({ model: 'haiku', permissionMode: 'auto' }),
launch({ permissionMode: 'auto' }, [{ id: 'opus[1m]', isDefault: true }])
]
for (const autoPick of unconfirmed) {
await expect(autoPick()).resolves.toBe('ask')
expect(await stoppedAfterLaunch('full-access', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('accept-edits', autoPick)).toBe(true)
expect(await stoppedAfterLaunch('ask', autoPick)).toBe(false)
}
})

it('never restarts a child it just started, from an alias or a resolved id', async () => {
const records: Record<string, string>[] = [
{ permissionMode: 'auto' },
{ model: 'default', permissionMode: 'auto' },
{ model: 'opus[1m]', permissionMode: 'auto' },
{ model: 'sonnet', permissionMode: 'auto' },
{ model: 'haiku', permissionMode: 'auto' },
{ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' }
]
const runsAuto = [true, true, true, true, false, false]
for (const [index, options] of records.entries()) {
const current = launch(options)
const launched = await current()
expect(launched).toBe(runsAuto[index] ? 'auto' : 'ask')
expect(await stoppedAfterLaunch(launched!, current)).toBe(false)
}
})

// A resolved ID initially launches Ask; the reported catalog alias enables auto after one restart.
it('settles after one restart once a resolved id is reported as its alias', async () => {
const first = await launch({ model: 'claude-opus-5-5[1m]', permissionMode: 'auto' })()
const reported = launch({ model: 'opus[1m]', permissionMode: 'auto' })
expect(await stoppedAfterLaunch(first!, reported)).toBe(true)
expect(await stoppedAfterLaunch((await reported())!, reported)).toBe(false)
})
})

it('never reads the mode for a child launched with none', async () => {
const current = now('full-access')
expect((await retire({}, {}, undefined, current)).stopAgent).not.toHaveBeenCalled()
Expand Down
28 changes: 2 additions & 26 deletions src/shared/agent-chat-permission-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@ import {
AGENT_CHAT_PERMISSION_MODES,
agentChatPermissionModes,
chatModelSupportsAutoMode,
chatPermissionModeNeedsModelSupport,
effectiveChatPermissionMode,
loadedChatPermissionSetting,
newChatPermissionMode,
offeredChatPermissionModes
Expand All @@ -30,7 +28,7 @@ describe('agent chat permission modes', () => {
it('seeds a new chat from the host setting, narrowed to what its agent offers', () => {
expect(newChatPermissionMode('codex', 'accept-edits')).toBe('ask')
expect(newChatPermissionMode('claude', 'accept-edits')).toBe('accept-edits')
// Claude's auto is narrowed at launch by the model, so the chat keeps its choice.
// Claude itself narrows auto by model, so the chat keeps its choice.
expect(newChatPermissionMode('claude', 'auto')).toBe('auto')
expect(newChatPermissionMode('codex', 'full-access')).toBe('full-access')
expect(newChatPermissionMode('claude', 'retired-mode')).toBe('ask')
Expand All @@ -41,29 +39,7 @@ describe('agent chat permission modes', () => {
})
})

describe('the mode a chat runs', () => {
it('runs Claude auto only on a model listed as supporting it', () => {
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', true)).toBe('auto')
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'auto', false)).toBe('ask')
expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'auto')).toBe(true)
expect(chatPermissionModeNeedsModelSupport(CLAUDE_CHAT_PERMISSION_MODES, 'ask')).toBe(false)
})

it('runs Codex auto on any model', () => {
expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'auto', false)).toBe('auto')
expect(chatPermissionModeNeedsModelSupport(CODEX_CHAT_PERMISSION_MODES, 'auto')).toBe(false)
})

it('asks for a chat saved before modes existed, or with a value its agent cannot run', () => {
expect(effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, undefined, true)).toBe('ask')
expect(effectiveChatPermissionMode(CODEX_CHAT_PERMISSION_MODES, 'accept-edits', true)).toBe(
'ask'
)
expect(
effectiveChatPermissionMode(CLAUDE_CHAT_PERMISSION_MODES, 'bypassPermissions', true)
).toBe('ask')
})

describe('the modes a chat offers', () => {
it('offers Approve for me only where the model runs it', () => {
expect(offeredChatPermissionModes(CLAUDE_CHAT_PERMISSION_MODES, false)).toEqual([
'ask',
Expand Down
19 changes: 0 additions & 19 deletions src/shared/agent-chat-permission-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,25 +70,6 @@ export function chatModelSupportsAutoMode(
return row?.supportsAutoMode === true
}

export function chatPermissionModeNeedsModelSupport(
support: AgentChatPermissionModeSupport,
intent: unknown
): boolean {
return (
support.autoNeedsModelSupport === true && savedChatPermissionMode(support, intent) === 'auto'
)
}

/** Saved choice, with `auto` on a model not listed for it read as Ask; never written back. */
export function effectiveChatPermissionMode(
support: AgentChatPermissionModeSupport,
intent: unknown,
autoSupported: boolean
): AgentChatPermissionMode {
const mode = savedChatPermissionMode(support, intent)
return mode === 'auto' && support.autoNeedsModelSupport && !autoSupported ? 'ask' : mode
}

export function offeredChatPermissionModes(
support: AgentChatPermissionModeSupport,
autoSupported: boolean
Expand Down
Loading