mirror of
https://github.com/stablyai/orca.git
synced 2026-11-02 10:08:13 +00:00
* 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
688 lines
26 KiB
TypeScript
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
|
|
})
|
|
}
|
|
}
|
|
})
|