Files
orca/tests/e2e/structured-agent-session-launch-admission.unit.test.ts
T
Brennan Benson f06f737802 Unify Source Control action permissions and card layout (#30449)
* Source control actions start chats and terminals on their saved model

A "start an agent" action's model lived only in free-text CLI arguments, which a native chat
never reads, so AI Fix opened a chat on the default model. Each action now saves a structured
model and effort that both routes apply: chat create options, or the agent's own flags in a
terminal (winning over the same flag in CLI arguments). Desktop, dialog, fix-checks and mobile
launches carry it; Settings rows get Model/Effort selects and the CLI arguments field says it is
terminal-only.

Tracked in STA-11102.

* Name the model in the repository override note

* Type the dialog's model hand-off; keep Agent in English in Japanese copy

* fix: preserve action chat model choices through launch

* Preserve action selection ownership across chat launches

* Keep terminal host contract fixtures aligned with preflight

* fix: wait for action Settings agent inventory

* fix: consolidate direct agent routing imports

* fix: use standard action option labels

* fix: align action launch contracts with shared types

* fix: sync action option localization metadata

* test: provide action Settings inventory in mounted fixtures

* Keep action options row height stable while loading

* Let each Source Control action choose its chat permission mode

An action's saved chat choice may now carry a permission mode, with or without a model;
"Same as new chats" saves nothing. A model is replaced only when the action names one,
so a permission-only choice keeps the chat's remembered model and options.

* fix(native-chat): leave older hosts their own permission mode and keep action options readable

- An action's saved permission mode is sent only to hosts that advertise
  agentSession.create.options-mode.v1. Older hosts get no mode, in create
  options or a later setOption, so the chat starts on that host's new-chat
  default, as a phone launch to such a host already does. Hosts from
  1.4.225 to 1.4.227 refused a mode their tables lacked (OpenCode, Grok,
  OMP), leaving the action unable to start.
- Settings action rows give Model and Permissions full rows in the options
  column so saved values are readable; the loading placeholder reserves the
  same rows.
- Remove two unused locale keys; fix two stale comments.
- Tests: the older-host create carries no permission mode; a permission
  mode picked alone keeps the remembered model on an acquired create.

* fix(settings): reserve one options row while loading an action with nothing saved

Terminal and permissions-only rows no longer shrink when loading ends; a chat
agent with a model list may grow by its Model row once known.

* fix(native-chat): keep the create options mode a checked literal type

* fix(settings): share verified action option fields

* fix(settings): keep action selectors on standard typography

* Keep the launch files under their length limits after the main merge

The structured route imports its result types where they are defined, and the two older-host
action-launch tests share one setup helper.

* One action settings section for chat and terminal launches

A Source Control action's Model, model options and Permissions now apply whether it opens a
chat or a terminal; terminals receive them as the agent's own flags, and a repository's own
CLI arguments keep the flags it sets. Extra CLI arguments stay terminal-only.

* fix(source-control-ai): one terminal projection for saved action choices

Ask in a terminal now states its permission instead of leaving it to the
user's own config: Claude and Grok pass --permission-mode default, Codex
passes -a on-request -s read-only -c approvals_reviewer="user" (the
interactive CLI has no untrusted policy, so a read-only sandbox is what
asks before edits). Agents whose CLI has no flag for a mode show the
existing "Applies to chats only" hint.

The strict launch-preferences projection now requires the launch agent at
every caller, composes model ids through the resolver's own step, carries
a mode only where the agent's terminal has a flag for it, and the local
string wire keeps booleans as 'true'/'false', so Cursor Thinking/Fast reach
the terminal and an OpenCode permission-only choice is not refused.

The argument parser knows flag arity, so removing a boolean permission
flag keeps other options' values, following positionals and '--'.
Pre-admission and executor share one terminal admission check on the
options the terminal will receive. Customize with Save launches the recipe
as it will resolve once saved; legacy partial repository overrides are
edited as the complete recipe they resolve to. Empty Extra CLI arguments
start collapsed.

* fix(source-control-ai): an edited repository action saves complete

Every Settings edit of a repository action (agent, model, permission,
prompt or arguments) now starts from the complete recipe the action
resolves to, whether its stored override is owned or a legacy partial.
The resolved agent selection is kept (never an agent only detected to run
a default), an explicit null args clear stays null, and empty args stay
empty instead of being dropped. Clearing the last preference therefore
means the action agent's own defaults rather than re-inheriting Global.

The action text draft is lossless: unset or cleared prompt and args stay
so until the user types, and the inputs show them as empty, so saving a
prompt no longer writes empty args that suppress the agent's default
flags. Untouched sibling actions are not rewritten by the edit.

* fix(settings): style the Extra CLI arguments toggle through Button's own variant and size

CI's changed-code quality check refuses restyle classes on Button and
CollapsibleContent. Use the ghost variant at size xs with a margin, and
put the content spacing on a plain inner wrapper.

* fix: keep saved action and restart launch recipes consistent

* fix: describe inherited arguments and chat-only permissions accurately

* test: check repository action settings fixture type

* test: map saved action options into terminal compiler inputs

* fix: retain saved action controls during agent detection

* fix(i18n): retire obsolete Portuguese action labels

* test: align folder launch fallback mock with outcome export

* fix: carry resolved connector launch intent and mode together

* test: pin shared action launch branches to recorded agent output

* test: preserve saved source control choices through detection and migration

* test: align launch fixtures with integrated result contract

* test: retain recipe and restart contracts after main integration

* Unify Source Control action permission settings

* Keep native Ask copy with its owning change

* Preserve action drafts and old-host permission intent

* Consume rejected Source Control action saves

* Follow the moved Settings persistence fixture

* Keep permission integration contracts in the cross-version test project

* Include the action permission inheritance label in English catalogs

* Align launch contracts with shared permission recipes

* Keep Source Control action controls within their Settings columns

* Use primitive variants for compact action settings controls

* Show action arguments only for terminal defaults and widen recipe rows

* Apply terminal-only action arguments visibility to repository overrides

* Align action permission control with model controls

* Use shared settings label for action permissions

* Hide action model controls until argument migration ships
2026-10-26 16:01:56 -07:00

688 lines
26 KiB
TypeScript

// @vitest-environment happy-dom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultPersistedState } from '../../src/shared/constants'
import type { PersistedState } from '../../src/shared/persisted-state-types'
import { normalizeLoadedGlobalSettings } from '../../src/main/persistence/loading-store/normalize-loaded-global-settings'
import { prepareLoadedTerminalSettings } from '../../src/main/persistence/loading-store/prepare-loaded-terminal-settings'
import { prepareLoadedProfileSettings } from '../../src/main/persistence/loading-store/prepare-loaded-profile-settings'
import { useNativeChatHostPermissionGate } from '@/components/native-chat/use-native-chat-host-permission-gate'
import * as runtimeRpc from '@/runtime/runtime-rpc-client'
import type * as HostPermission from '@/runtime/structured-chat-host-permission'
import type { Worktree } from '../../src/shared/worktree/types'
const mocks = vi.hoisted(() => ({
createSupport: vi.fn(),
toastInfo: vi.fn(),
toastError: vi.fn(),
launchAgentInNewTab: vi.fn(),
hostPermission: vi.fn()
}))
vi.mock('sonner', () => ({ toast: { info: mocks.toastInfo, error: mocks.toastError } }))
vi.mock('@/lib/launch-agent-in-new-tab', () => ({
launchAgentInNewTabWithOutcome: mocks.launchAgentInNewTab
}))
vi.mock('@/lib/ai-vault-session-resume-preparation', () => ({
prepareAiVaultSessionForResume: async () => ({ sessionId: 'provider-1' })
}))
vi.mock('@/runtime/structured-agent-session-client', () => ({
callStructuredAgentSession: vi.fn(async (_target: unknown, method: string) => {
if (method === 'agentSession.createSupport') {
return mocks.createSupport()
}
return new Promise(() => undefined)
})
}))
vi.mock('@/runtime/structured-chat-host-permission', async (importOriginal) => ({
...(await importOriginal<typeof HostPermission>()),
readStructuredChatHostPermissionVerdict: mocks.hostPermission
}))
// Switch-on, main opens this machine's chats and its frames draw them.
vi.mock('@/runtime/runtime-rpc-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeRpcClientWithLayoutApply(await importOriginal())
)
vi.mock('@/runtime/runtime-layout-client', async (importOriginal) =>
(
await import('@/runtime/layout-apply-test-transport.test-fixture')
).runtimeLayoutClientWithTestFrames(await importOriginal())
)
import { useAppStore } from '@/store'
import { adoptAgentSessionLaunchVerdict } from '../../src/renderer/src/lib/agent-session-launch-plan'
import { beginStructuredAgentSessionProvisionalLaunch } from '../../src/renderer/src/lib/agent-session/structured-agent-session-provisional-tab'
import { beginDirectWorkItemStructuredLaunch } from '../../src/renderer/src/lib/launch-work-item-direct-agent-routing'
import { getStructuredAgentLaunchStatus } from '../../src/renderer/src/lib/structured-agent-session-launch-status'
import { getStructuredAgentSessionLaunchSelection } from '../../src/renderer/src/lib/structured-agent-session-launch-options'
import { peekWebSessionFocusIntent } from '@/runtime/web-session-focus-intent'
import { LOCAL_ADMISSION_WAIT_MS } from '../../src/renderer/src/lib/structured-agent-session-host-admission'
import { launchStructuredAgentFromNewTab } from '../../src/renderer/src/lib/launch-agent-in-new-tab-structured-route'
import { structuredAgentSessionFocusOwner } from '@/runtime/structured-agent-session-owner'
import {
drawTabsFromMain,
type TabsDrawnFromMain
} from '@/store/slices/tabs-drawn-from-main.test-fixture'
const WORKTREE = 'repo-1::/srv/app'
const INITIAL_SETTINGS = useAppStore.getState().settings
function pairedPlan(
overrides: { resumeFrom?: { providerSessionId: string }; executionHostId?: 'local' } = {}
) {
return adoptAgentSessionLaunchVerdict({
requestId: 'request-1',
route: 'structured-native-chat',
agent: 'claude',
worktreeId: WORKTREE,
executionHostId: 'runtime:server-1',
prompt: 'fix the flaky test',
promptDelivery: 'auto-submit',
...overrides
})
}
const localPlan = () => pairedPlan({ executionHostId: 'local' })
/** The workspace's row names the paired server as its owner, whose layout draws its tabs. */
function seedPairedServerWorkspace(): void {
const worktree: Worktree = {
id: WORKTREE,
repoId: 'repo-1',
path: '/srv/app',
head: 'abc',
branch: 'refs/heads/main',
isBare: false,
isMainWorktree: true,
displayName: 'app',
comment: '',
linkedIssue: null,
linkedPR: null,
linkedLinearIssue: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
hostId: 'runtime:server-1',
runtimeOwnerEnvironmentId: 'server-1'
}
useAppStore.setState({ worktreesByRepo: { 'repo-1': [worktree] } })
}
/** Nothing of a chat exists on this machine: no tab, launch, record, queued prompt or intent. */
function expectNoChatCommitted(): void {
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
expect(getStructuredAgentLaunchStatus(WORKTREE, 'claude')).toBe('idle')
expect(Object.keys(localStorage)).toEqual([])
expect(peekWebSessionFocusIntent({ environmentId: 'server-1' }, WORKTREE)).toBeNull()
expect(
peekWebSessionFocusIntent(structuredAgentSessionFocusOwner({ kind: 'local' }), WORKTREE)
).toBeNull()
}
let main: TabsDrawnFromMain
beforeEach(async () => {
vi.clearAllMocks()
mocks.hostPermission.mockResolvedValue({ kind: 'allowed' })
localStorage.clear()
useAppStore.setState({
activeWorktreeId: WORKTREE,
activeWorkspaceExecutionHostId: null,
unifiedTabsByWorktree: {},
worktreesByRepo: {},
settings: INITIAL_SETTINGS
})
main = await drawTabsFromMain(useAppStore)
})
afterEach(() => {
main.close()
vi.restoreAllMocks()
})
describe('a structured chat launch on a paired server', () => {
beforeEach(seedPairedServerWorkspace)
it('opens only the terminal when the server declines, keeping the workspace selected', async () => {
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
const onHostDeclined = vi.fn(() => ({
opened: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
}))
const reveal = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
beforeOpen: reveal,
onHostDeclined
})
expect(launch?.tab).toBeNull()
expect(reveal).toHaveBeenCalledOnce()
await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' })
await expect(launch?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(onHostDeclined).toHaveBeenCalledOnce()
expect(mocks.toastInfo).toHaveBeenCalledWith(expect.any(String), {
description: expect.stringContaining("can't run")
})
expect(useAppStore.getState().activeWorktreeId).toBe(WORKTREE)
expectNoChatCommitted()
})
it("opens a direct caller's terminal with the caller's own CLI arguments on a decline", async () => {
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'host-published' } })
const result = beginDirectWorkItemStructuredLaunch({
plan: pairedPlan(),
primaryTabId: null,
beforeOpen: vi.fn(),
declinedTerminal: { agentArgs: '--model opus', launchSource: 'task_page' }
})
expect(result).toEqual({ completed: true, structuredLaunch: true, primaryTabId: null })
await vi.waitFor(() => expect(mocks.launchAgentInNewTab).toHaveBeenCalledOnce())
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
expect.objectContaining({
agent: 'claude',
worktreeId: WORKTREE,
prompt: 'fix the flaky test',
agentArgs: '--model opus',
launchSource: 'task_page',
agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' })
})
)
expectNoChatCommitted()
})
it('uses actual discovery outcome for both decline callers, preserving failed-refresh and shell fallback', async () => {
const worktreeId = FLOATING_TERMINAL_WORKTREE_ID
const plan = () => adoptAgentSessionLaunchVerdict({ ...localPlan(), worktreeId })
useAppStore.getState().clearLocalDetectedAgents()
const detect = vi.fn().mockResolvedValue([])
const refresh = vi.fn().mockRejectedValue(new Error('failed'))
Object.defineProperty(window, 'api', {
configurable: true,
value: { preflight: { detectAgents: detect, refreshAgents: refresh } }
})
await useAppStore.getState().ensureNativeDetectedAgents()
mocks.createSupport.mockResolvedValue({ supported: false })
const openTerminal = vi.fn(() => ({
surface: { kind: 'host-published' as const },
pasteDraftAfterLaunch: false
}))
const declined = () =>
launchStructuredAgentFromNewTab({ plan: plan(), worktreeId, openTerminal })
await expect(declined()?.structuredSettlement).resolves.toMatchObject({ kind: 'failed' })
const direct = () =>
beginDirectWorkItemStructuredLaunch({
plan: plan(),
primaryTabId: null,
beforeOpen: vi.fn(),
declinedTerminal: { agentArgs: '--model opus', launchSource: 'task_page' }
})
direct()
await vi.waitFor(() => expect(mocks.toastError).toHaveBeenCalledTimes(2))
// The action-first chat tab never swaps to a CLI terminal the host lacks.
const actionFirst = beginStructuredAgentSessionProvisionalLaunch({
plan: adoptAgentSessionLaunchVerdict({ ...plan(), prompt: '' }),
hooks: {},
focusIntent: { request: vi.fn(), cancel: vi.fn() }
})
expect(actionFirst?.tab).not.toBeNull()
await expect(actionFirst?.settlement).resolves.toMatchObject({ kind: 'failed' })
expect(openTerminal).not.toHaveBeenCalled()
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
await useAppStore.getState().refreshDetectedAgents(worktreeId)
const inventory = useAppStore.getState().readLocalAgentInventory(worktreeId)
expect(inventory).toEqual({ state: 'failed', ids: [] })
await expect(declined()?.structuredSettlement).resolves.toEqual({ kind: 'terminal' })
mocks.launchAgentInNewTab.mockReturnValue({
surface: { kind: 'local-terminal', tabId: 'fallback' }
})
direct()
await vi.waitFor(() => expect(mocks.launchAgentInNewTab).toHaveBeenCalledOnce())
expect(mocks.launchAgentInNewTab).toHaveBeenCalledWith(
expect.objectContaining({ agentArgs: '--model opus', prompt: 'fix the flaky test' })
)
const seedShell = vi.fn(() => ({ opened: true }))
await expect(
launchStructuredAgentFromNewTab({
plan: plan(),
worktreeId,
onHostDeclined: seedShell,
openTerminal
})?.structuredSettlement
).resolves.toEqual({ kind: 'terminal' })
expect(seedShell).toHaveBeenCalledOnce()
})
it('opens nothing and says so once when the server cannot be reached', async () => {
mocks.createSupport.mockRejectedValue(new Error('connection lost'))
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined
})
await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' })
await expect(launch?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: true
})
expect(mocks.toastError).toHaveBeenCalledOnce()
expect(onHostDeclined).not.toHaveBeenCalled()
expectNoChatCommitted()
})
it('leaves no destination when the server declines a resume', async () => {
mocks.createSupport.mockResolvedValue({ supported: false })
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan({ resumeFrom: { providerSessionId: 'provider-1' } }),
hooks: {},
onHostDeclined
})
await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' })
expect(mocks.createSupport).toHaveBeenCalledOnce()
expect(onHostDeclined).not.toHaveBeenCalled()
expectNoChatCommitted()
})
// The picker shows what create will run: the server's saved selection, not this machine's.
it('seeds the chat with the selection the admitting server reported', async () => {
mocks.createSupport.mockResolvedValue({ supported: true, seedOptions: { model: 'opus' } })
useAppStore.setState({
settings: {
...useAppStore.getState().settings!,
nativeChatSessionOptions: { claude: { model: 'sonnet' } }
}
})
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1)
)
const sessionId = useAppStore.getState().unifiedTabsByWorktree[WORKTREE]![0]!.entityId
expect(getStructuredAgentSessionLaunchSelection(sessionId)?.seed).toEqual({ model: 'opus' })
launch?.cancel()
})
// An older server ignores the chat's mode and would run Claude with its own bypass flag.
it.each(['refused', 'unverified'] as const)(
'starts nothing on a server whose permission verdict is %s, and says so once',
async (kind) => {
mocks.createSupport.mockResolvedValue({ supported: true })
mocks.hostPermission.mockResolvedValue(
kind === 'refused' ? { kind, hostMode: 'full-access' } : { kind }
)
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
const settlement = await launch?.settlement
expect(mocks.hostPermission).toHaveBeenCalledWith(
{ kind: 'environment', environmentId: 'server-1' },
'claude'
)
expect(settlement).toEqual(expect.objectContaining({ kind: 'failed', notified: true }))
expect(mocks.toastError).toHaveBeenCalledWith(expect.stringContaining('Claude'), {
description: expect.stringMatching(
kind === 'unverified' ? /confirm the host's permissions for Claude/ : /older Orca/
)
})
expect(mocks.launchAgentInNewTab).not.toHaveBeenCalled()
expectNoChatCommitted()
}
)
it.each(['own', 'ask'] as const)(
'loads and reloads saved %s through old-host launch and mounted Send admission',
async (choice) => {
const defaults = getDefaultPersistedState('/fixture/c3-old-host')
let parsed: PersistedState = {
...defaults,
settings: {
...defaults.settings,
agentPermissionDefault: choice,
agentPermissionOverrides: {},
agentYoloDefaultsMigrated: true,
agentDefaultArgs: { claude: '', codex: '' },
nativeChatPermissionMode: 'full-access'
}
}
for (let load = 0; load < 2; load++) {
const noop = (): void => {}
const settings = normalizeLoadedGlobalSettings(
parsed,
prepareLoadedTerminalSettings(parsed, noop),
prepareLoadedProfileSettings(parsed, defaults, noop)
)
expect(settings.agentPermissionDefault).toBe(choice)
expect(settings.nativeChatPermissionMode).toBe('ask')
useAppStore.setState({ settings })
parsed = { ...parsed, settings: JSON.parse(JSON.stringify(settings)) }
}
const actual = await vi.importActual<typeof HostPermission>(
'@/runtime/structured-chat-host-permission'
)
mocks.hostPermission.mockImplementation(actual.readStructuredChatHostPermissionVerdict)
let answerCapability: (supported: boolean) => void = () => {}
const capability = new Promise<boolean>((resolve) => {
answerCapability = resolve
})
const capabilityRead = vi
.spyOn(runtimeRpc, 'runtimeEnvironmentSupportsCapability')
.mockReturnValue(capability)
let answerSettings: (value: {
settings: { agentDefaultArgs: Record<string, string> }
}) => void = () => {}
const hostSettings = new Promise<{ settings: { agentDefaultArgs: Record<string, string> } }>(
(resolve) => {
answerSettings = resolve
}
)
const settingsRead = vi.spyOn(runtimeRpc, 'callRuntimeRpc').mockReturnValue(hostSettings)
mocks.createSupport.mockResolvedValue({ supported: true })
const mounted = renderHook(() =>
useNativeChatHostPermissionGate(
{ kind: 'environment', environmentId: 'server-1' },
'claude',
'Claude'
)
)
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
await vi.waitFor(() => expect(capabilityRead).toHaveBeenCalledTimes(2))
expect(settingsRead).not.toHaveBeenCalled()
expect(mounted.result.current.sendBlockedReason).not.toBeNull()
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
await act(async () => answerCapability(false))
await vi.waitFor(() => expect(settingsRead).toHaveBeenCalledTimes(2))
for (const call of settingsRead.mock.calls) {
expect(call).toEqual([
{ kind: 'environment', environmentId: 'server-1' },
'settings.get',
undefined,
{ timeoutMs: 15_000 }
])
}
expect(mounted.result.current.sendBlockedReason).not.toBeNull()
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
await act(async () => answerSettings({ settings: { agentDefaultArgs: {} } }))
if (choice === 'own') {
await vi.waitFor(() => expect(mounted.result.current.sendBlockedReason).toBeNull())
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1)
)
expect(mocks.toastError).not.toHaveBeenCalled()
} else {
await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed', notified: true })
await vi.waitFor(() =>
expect(mounted.result.current.notice?.key).toBe('host-permission:refused')
)
expectNoChatCommitted()
}
mounted.unmount()
launch?.cancel()
}
)
it('opens the chat on the server that admitted it', async () => {
mocks.createSupport.mockResolvedValue({ supported: true })
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toEqual([
expect.objectContaining({
contentType: 'agent-session',
executionHostId: 'runtime:server-1'
})
])
)
launch?.cancel()
})
})
// The same ask, of this machine's own runtime: a Claude account bound to WSL is one "no" it gives.
describe('a structured chat launch on this machine', () => {
it('opens only the terminal when this machine declines, without a notice', async () => {
mocks.createSupport.mockResolvedValue({ supported: false, reason: 'wsl' })
const onHostDeclined = vi.fn(() => ({
opened: true,
promptDeliveryResult: Promise.resolve({ delivered: true, failureNotified: false })
}))
const reveal = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
beforeOpen: reveal,
onHostDeclined
})
expect(launch?.tab).toBeNull()
expect(reveal).toHaveBeenCalledOnce()
await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' })
await expect(launch?.promptDeliveryResult).resolves.toEqual({
delivered: true,
failureNotified: false
})
expect(onHostDeclined).toHaveBeenCalledExactlyOnceWith({ kind: 'local' }, undefined)
expect(mocks.toastInfo).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
expectNoChatCommitted()
})
it("opens a new agent tab's terminal with the typed prompt when the caller names none", async () => {
mocks.createSupport.mockResolvedValue({ supported: false })
mocks.launchAgentInNewTab.mockReturnValue({ surface: { kind: 'local-terminal', tabId: 't' } })
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
targetGroupId: 'group-1',
declinedTerminal: { agentArgs: '--model opus' }
})
await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' })
expect(mocks.launchAgentInNewTab).toHaveBeenCalledExactlyOnceWith(
expect.objectContaining({
agent: 'claude',
worktreeId: WORKTREE,
groupId: 'group-1',
prompt: 'fix the flaky test',
agentArgs: '--model opus',
agentSessionLaunchPlan: expect.objectContaining({ route: 'terminal-tui' })
})
)
expectNoChatCommitted()
})
it('opens nothing when the launch is abandoned before this machine answers', async () => {
let answer!: (support: { supported: boolean }) => void
mocks.createSupport.mockReturnValue(new Promise((resolve) => (answer = resolve)))
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined
})
launch?.cancel()
answer({ supported: false })
await expect(launch?.settlement).resolves.toEqual({ kind: 'cancelled', sessionId: null })
await expect(launch?.promptDeliveryResult).resolves.toEqual({
delivered: false,
failureNotified: false
})
expect(onHostDeclined).not.toHaveBeenCalled()
expectNoChatCommitted()
})
it('never asks this machine for a permission verdict: it applies every chat mode', async () => {
mocks.createSupport.mockResolvedValue({ supported: true })
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1)
)
expect(mocks.hostPermission).not.toHaveBeenCalled()
launch?.cancel()
})
it('opens the chat once this machine admits it', async () => {
mocks.createSupport.mockResolvedValue({ supported: true })
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined: vi.fn()
})
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toEqual([
expect.objectContaining({ contentType: 'agent-session', executionHostId: 'local' })
])
)
launch?.cancel()
})
// This machine has no connection to lose: the chat opens, and its own start reports a failure.
it('opens the chat without a notice when this machine cannot answer the ask', async () => {
mocks.createSupport.mockRejectedValue(new Error('runtime not ready'))
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined
})
await vi.waitFor(() =>
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1)
)
expect(onHostDeclined).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
launch?.cancel()
})
// Not resolvable yet is not a "no": the chat opens and its own create asks again, as before.
it('opens the chat, not a terminal, when this machine cannot resolve a new workspace yet', async () => {
mocks.createSupport.mockRejectedValue(new Error('selector_not_found'))
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined
})
await vi.waitFor(
() => expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1),
{ timeout: 3_000 }
)
expect(onHostDeclined).not.toHaveBeenCalled()
launch?.cancel()
})
// The click must never wait without end: past the bound the chat opens and reports for itself.
it('opens the chat once the wait for this machine passes its bound', async () => {
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
try {
mocks.createSupport.mockReturnValue(new Promise(() => undefined))
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: localPlan(),
hooks: {},
onHostDeclined
})
await vi.advanceTimersByTimeAsync(LOCAL_ADMISSION_WAIT_MS - 1)
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE] ?? []).toEqual([])
await vi.advanceTimersByTimeAsync(1)
expect(useAppStore.getState().unifiedTabsByWorktree[WORKTREE]).toHaveLength(1)
expect(onHostDeclined).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
launch?.cancel()
} finally {
vi.useRealTimers()
}
})
})
describe('a paired server that cannot resolve a new workspace yet', () => {
beforeEach(seedPairedServerWorkspace)
it('keeps its terminal fallback, as before', async () => {
mocks.createSupport.mockRejectedValue(new Error('selector_not_found'))
const onHostDeclined = vi.fn(() => ({ opened: true }))
const launch = beginStructuredAgentSessionProvisionalLaunch({
plan: pairedPlan(),
hooks: {},
onHostDeclined
})
await expect(launch?.settlement).resolves.toEqual({ kind: 'terminal' })
expect(onHostDeclined).toHaveBeenCalledOnce()
})
})
it('keeps explicit refusal owned by admission and keeps an opened chat failure out of the menu', async () => {
seedPairedServerWorkspace()
for (const supported of [false, true]) {
mocks.toastError.mockClear()
mocks.createSupport.mockResolvedValue({ supported, reason: 'wsl' })
const plan = adoptAgentSessionLaunchVerdict({ ...pairedPlan(), requestedSurface: 'chat' })
if (supported) {
mocks.createSupport
.mockResolvedValueOnce({ supported: true })
.mockResolvedValueOnce({ supported: false })
}
const onHostDeclined = vi.fn()
const launch = beginStructuredAgentSessionProvisionalLaunch({ plan, hooks: {}, onHostDeclined })
await expect(launch?.settlement).resolves.toMatchObject({ kind: 'failed' })
expect(onHostDeclined).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledTimes(supported ? 0 : 1)
if (!supported) {
await expect(launch?.promptDeliveryResult).resolves.toMatchObject({
delivered: false,
failureNotified: true
})
}
}
})