diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d3407ae1454..b91ba2cdec1 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -358,6 +358,7 @@ "chatTurnActivity", "createComposerStagingCommands", "createComposerSubmissionCommands", + "createExecutorSessionActivator", "desktopSlashCommandPresentation", "executorComposerProps", "resolveTaskReadinessModelTarget", @@ -761,7 +762,7 @@ "react": 1 }, "importSpecifiers": 60, - "nonTriviaTokens": 8017 + "nonTriviaTokens": 8009 }, "src/renderer/use-app-shell-session-list.ts": { "importDeclarations": 4, diff --git a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts index 31cd9a2b0af..0fcdfa4e576 100644 --- a/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts +++ b/apps/desktop/src/main/__tests__/composer-submission-owner.test.ts @@ -26,7 +26,7 @@ import { act, createElement, Fragment } from 'react'; import type { StoredMessage } from '@maka/core/session'; import type { UserQuestionResponse } from '@maka/core/user-question'; import type { SandboxBoundaryResponse } from '@maka/core/sandbox-boundary'; -import { AstryxLocaleProvider, LocaleProvider, ToastProvider } from '@maka/ui'; +import { AstryxLocaleProvider, LocaleProvider, ToastProvider, type ComposerProps } from '@maka/ui'; import type { DesktopSessionSummary } from '../../shared/desktop-session-projection.js'; import { createSessionCatalogController, @@ -65,6 +65,8 @@ type Owner = { sessionId: string | undefined }; type ProviderProps = Parameters>[0]; interface RegionProps { + executorPicker?: ComposerProps['executorPicker']; + sendBlocked?: boolean; onSend(text: string, metadata?: { followUpMode?: 'steer' | 'queue' }): Promise; newTaskSendPending: boolean; revisionNotice?: { title: string; detail: string; cancelLabel: string; onCancel(): void }; @@ -100,6 +102,8 @@ function harness(options: { newTask?: Partial; sharedSessionActive?: boolean; listMessages?: ReturnType['listMessages']; + executorPicker?: ComposerProps['executorPicker']; + sendBlocked?: boolean; } = {}) { const { root } = installReactRenderer(); const catalog = createSessionCatalogController(); @@ -150,6 +154,7 @@ function harness(options: { }), createElement(ConversationComposerRegion, { surface: Composer, contextPickEnabled: true, directoryPickerEnabled: true, + executorPicker: options.executorPicker, sendBlocked: options.sendBlocked, }), ); } @@ -189,6 +194,9 @@ describe('ComposerSubmissionProvider', () => { const submitted: unknown[] = []; const admission = deferred>>(); const h = harness({ + executorPicker: { + catalog: [], onSelect() {}, onSetup() {}, onRetry() {}, onNewTask() {}, + }, services: { submitMessage: (sessionId, placement, command) => { submitted.push({ sessionId, placement, text: command.text }); @@ -199,10 +207,14 @@ describe('ComposerSubmissionProvider', () => { await act(async () => h.target.setActiveId('A')); await act(async () => h.published[0]!([userTurn('turn-1', 'earlier')])); assert.equal(h.region.newTaskSendPending, false); + assert.equal(h.region.executorPicker?.disabled, false); + assert.equal(h.region.sendBlocked, false); let sending!: Promise; await act(async () => { sending = h.region.onSend('hello'); }); assert.equal(h.region.newTaskSendPending, true, 'the flag is the owner\'s, read by the Composer slot'); + assert.equal(h.region.executorPicker?.disabled, true, 'executor changes stay locked until Host admission settles'); + assert.equal(h.region.sendBlocked, true); assert.deepEqual(submitted, [{ sessionId: 'A', placement: 'next_turn', text: 'hello' }]); await act(async () => { @@ -213,6 +225,20 @@ describe('ComposerSubmissionProvider', () => { assert.equal(await sending, true); }); assert.equal(h.region.newTaskSendPending, false); + assert.equal(h.region.executorPicker?.disabled, false); + assert.equal(h.region.sendBlocked, false); + }); + + test('retains existing executor and send gates while no submission is pending', () => { + const h = harness({ + executorPicker: { + catalog: [], disabled: true, onSelect() {}, onSetup() {}, onRetry() {}, onNewTask() {}, + }, + sendBlocked: true, + }); + assert.equal(h.region.newTaskSendPending, false); + assert.equal(h.region.executorPicker?.disabled, true); + assert.equal(h.region.sendBlocked, true); }); test('steers into the running Host Turn: the pending row names that Turn before the Host answers', async () => { diff --git a/apps/desktop/src/main/__tests__/executor-selection.test.ts b/apps/desktop/src/main/__tests__/executor-selection.test.ts index 1616973d24e..d41e37b806f 100644 --- a/apps/desktop/src/main/__tests__/executor-selection.test.ts +++ b/apps/desktop/src/main/__tests__/executor-selection.test.ts @@ -24,10 +24,215 @@ import { act, createElement } from 'react'; import { createRoot } from 'react-dom/client'; import type { ExecutorCatalogEntry } from '@maka/core/executor-catalog'; import type { SessionSummary } from '@maka/core/session'; -import { useExecutorSelection, newTaskConfiguration, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; +import { useExecutorSelection, newTaskConfiguration, executorSubmissionError, executorComposerProps, createExecutorSessionActivator, ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js'; const entry: ExecutorCatalogEntry = { id: 'external', displayName: 'External', readiness: 'ready', models: [{ id: 'selected', name: 'Selected' }], supportsAttachments: false, supportsModelChange: true }; +test('first send carries the selected catalog through inspection and Agent initialization', async () => { + const { document, window } = parseHTML('
'); + const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true }; + const originals = new Map(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + const root = createRoot(document.getElementById('root')!); + const discovered: ExecutorCatalogEntry = { + ...entry, + models: [{ id: 'selected', name: 'Gemini High' }, { id: 'low', name: 'Gemini Low' }, { id: 'default', name: 'Default model' }], + modelGroups: [{ id: 'gemini', name: 'Gemini', variants: [{ modelId: 'selected', level: 'high' }, { modelId: 'low', level: 'low' }] }], + modes: [{ id: 'default', name: 'Default' }], currentMode: 'default', supportsModeChange: true, + currentModel: 'default', + }; + let latest!: ReturnType; + const frames: Array> = []; + let finishInspection!: (value: readonly ExecutorCatalogEntry[]) => void; + const pending = new Promise(resolve => { finishInspection = resolve; }); + let inspection = pending; + let observed: readonly ExecutorCatalogEntry[] | undefined; + let sessionPending = false; + let inspectionCalls = 0; + const services = { + subscribeChanges: () => () => {}, + newTasks: { subscribeChanges: () => () => {}, getExecutors: async () => [discovered] }, + sessions: { getExecutorState: () => { + inspectionCalls++; + if (sessionPending) throw new Error('Session not found'); + return observed ? Promise.resolve(observed) : inspection; + } }, + } as unknown as ConversationServices; + const session = { id: 'created', executorId: 'external', model: 'selected', executorConfig: { model: 'selected' } } as SessionSummary; + function Probe(props: { session?: SessionSummary }) { + latest = useExecutorSelection({ key: 'draft', cwd: '/fixture', target: { hostId: 'host', profileId: 'profile', projectId: null }, session: props.session, ...{ sessionPending } }); + frames.push(latest); + return null; + } + const render = async (value?: SessionSummary) => { + await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe, { session: value }) }))); + }; + const assertDisplay = () => { + assert.equal(latest.selection?.configuration.model, 'selected'); + assert.equal(latest.entry?.displayName, 'External'); + assert.deepEqual(latest.entry?.modelGroups, discovered.modelGroups); + assert.deepEqual(latest.entry?.modes, discovered.modes); + assert.equal(latest.selection?.configuration.mode, 'default'); + assert.equal(latest.error, undefined); + }; + try { + await render(); + await act(async () => latest.select({ executorId: 'external', configuration: { model: 'selected' } })); + const sending = executorComposerProps(latest, { activeId: undefined, turnActive: false, sendPending: true, taskSubmissionHardBlocked: false, connectionCount: 0, onSetup() {}, onNewTask() {} }); + assert.equal(sending.executorPicker?.disabled, true, 'controls lock before Host admission'); + assert.equal(sending.sendBlocked, true, 'Session handoff cannot admit a duplicate first send'); + const localSession = { ...session, model: 'external', executorConfig: undefined }; + const activation: string[] = []; + await createExecutorSessionActivator(latest, () => { + activation.push('commit'); + }, selection => { + assert.equal(selection.section, 'sessions'); + activation.push('navigation'); + }, id => { + assert.equal(id, localSession.id); + activation.push('activate'); + })(localSession); + assert.deepEqual(activation, ['commit', 'navigation', 'activate']); + assert.equal(latest.entry?.displayName, 'External', 'adoption does not erase the still-visible draft'); + const start = frames.length; + sessionPending = true; + await render(localSession); + assert.equal(inspectionCalls, 0, 'a locally pending Session must not inspect a nonexistent Host Session'); + assert.equal(latest.loading, true); + assertDisplay(); + await act(async () => latest.refresh(true)); + assert.equal(inspectionCalls, 0, 'catalog invalidations cannot bypass local admission'); + assertDisplay(); + sessionPending = false; + await render(session); + assert.equal(latest.loading, true); + assertDisplay(); + await act(async () => { + finishInspection([{ ...entry, models: [{ id: 'selected', name: 'selected' }], currentModel: 'selected', supportsModelChange: false }]); + await pending; + }); + assertDisplay(); + assert.equal(latest.entry?.supportsModelChange, false, 'presentation does not invent Session capabilities'); + observed = [{ ...entry, models: [{ id: 'selected', name: 'selected' }], currentModel: 'selected', supportsModelChange: false, + modes: discovered.modes, currentMode: 'default', supportsModeChange: true }]; + await act(async () => latest.refresh()); + assertDisplay(); + assert.equal(latest.entry?.supportsModeChange, true, 'mode readiness does not erase pending model presentation'); + observed = [discovered]; + await act(async () => latest.refresh()); + assertDisplay(); + observed = [{ ...entry, models: [{ id: 'selected', name: 'selected' }], currentModel: 'selected', supportsModelChange: false }]; + await act(async () => latest.refresh()); + assertDisplay(); + assert.equal(latest.entry?.supportsModelChange, false, 'a later configuration-only inspection keeps its real capabilities'); + observed = [discovered]; + await render({ ...session, executorConfig: undefined }); + assert.equal(latest.selection?.configuration.model, 'default', 'a Host Session without explicit config follows inspection'); + await render(session); + assertDisplay(); + assert.ok(frames.slice(start).every(frame => frame.entry?.models.some(model => model.id === frame.selection?.configuration.model)), 'no committed frame loses its selected model metadata'); + observed = [{ ...discovered, models: [{ id: 'default', name: 'Default model' }], modelGroups: [], modes: [], supportsModeChange: false }]; + await act(async () => latest.refresh()); + assert.deepEqual(latest.catalog, observed, 'an authoritative removal must not be hidden by known presentation'); + observed = [{ ...entry, readiness: 'unavailable', models: [] }]; + await act(async () => latest.refresh()); + assert.equal(latest.entry?.readiness, 'unavailable', 'real failure replaces the initial presentation'); + observed = undefined; + inspection = new Promise(() => {}); + await render({ ...session, id: 'other-session' }); + assert.equal(latest.entry, undefined, 'another Session cannot inherit the submitted draft catalog'); + } finally { + await act(async () => root.unmount()); + for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } +}); + +test('a plugin without discovery accepts followups while pending local summaries retain their model', async () => { + const { document, window } = parseHTML('
'); + const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true }; + const originals = new Map(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + const root = createRoot(document.getElementById('root')!); + let latest!: ReturnType; + const services = { + subscribeChanges: () => () => {}, + newTasks: { subscribeChanges: () => () => {} }, + sessions: { getExecutorState: async () => [{ ...entry, models: [], supportsModelChange: false }] }, + } as unknown as ConversationServices; + const session = { id: 'saved', executorId: 'external', model: 'workhub-default' } as SessionSummary; + function Probe(props: { sessionPending: boolean; session: SessionSummary }) { + latest = useExecutorSelection({ key: 'saved', ...props }); + return null; + } + const render = async (sessionPending: boolean, value = session) => { + await act(async () => root.render(createElement(ConversationServicesProvider, { + services, children: createElement(Probe, { sessionPending, session: value }), + }))); + }; + try { + await render(true); + assert.equal(latest.selection?.configuration.model, 'workhub-default'); + await render(false); + assert.deepEqual(latest.selection?.configuration, {}); + assert.equal(executorSubmissionError({ executorSelection: latest.selection, executorEntry: latest.entry }, 0, 'en'), undefined); + await render(false, { ...session, executorConfig: { model: 'removed-model' } }); + assert.match(executorSubmissionError({ executorSelection: latest.selection, executorEntry: latest.entry }, 0, 'en') ?? '', /no longer available/); + } finally { + await act(async () => root.unmount()); + for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } +}); + +for (const modeState of ['removed', 'replaced', 'valid', 'unknown-model', 'unknown-capability'] as const) { + test(`explicit restore recovers a ${modeState} mode only when inspection confirms its removal`, async () => { + const { document, window } = parseHTML('
'); + const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true }; + const originals = new Map(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + const root = createRoot(document.getElementById('root')!); + let latest!: ReturnType; + const configuration = { model: 'selected', mode: 'ask' }; + const expected = modeState === 'removed' ? { model: 'selected' } + : modeState === 'replaced' ? { model: 'selected', mode: 'auto' } : configuration; + const writes: unknown[] = []; + let ready = false; + const services = { + subscribeChanges: () => () => {}, + newTasks: { subscribeChanges: () => () => {} }, + sessions: { + getExecutorState: async () => [{ + ...entry, readiness: ready ? 'ready' : 'restore_failed', + currentModel: modeState === 'unknown-model' && !ready ? 'other' : 'selected', + // ACP inspection may retain a saved mode label despite losing the option. + modes: [{ id: modeState === 'replaced' ? 'auto' : 'ask', name: 'Mode' }], + supportsModeChange: modeState === 'unknown-capability' ? undefined : modeState !== 'removed', + }], + setExecutorModelConfiguration: async (_id: string, config: unknown) => { + writes.push(config); + ready = true; + return { ok: true, session: { executorConfig: expected } }; + }, + }, + } as unknown as ConversationServices; + function Probe() { + latest = useExecutorSelection({ key: 'saved', session: { id: 'saved', executorId: 'external', executorConfig: configuration } as SessionSummary }); + return null; + } + try { + await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe) }))); + assert.equal(latest.entry?.readiness, 'restore_failed'); + await act(async () => latest.restore()); + assert.deepEqual(writes, [modeState === 'removed' || modeState === 'replaced' ? { model: 'selected' } : configuration]); + assert.deepEqual(latest.selection?.configuration, expected); + assert.equal(latest.entry?.readiness, 'ready'); + assert.equal(executorSubmissionError({ executorSelection: latest.selection, executorEntry: latest.entry }, 0, 'en'), undefined); + } finally { + await act(async () => root.unmount()); + for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } + }); +} + test('explicit restore confirms the saved model while preserving the current task', async () => { const { document, window } = parseHTML('
'); const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true }; @@ -205,7 +410,8 @@ test('late draft discovery cannot replace the current target; existing tasks ins assert.equal(latest.changing, false); serverModel = 'selected'; await act(async () => latest.refresh()); - assert.equal(latest.selection?.configuration.model, 'selected', 'Agent state updates synchronize the control'); + assert.equal(latest.entry?.currentModel, 'selected', 'inspection still reports observed Agent state'); + assert.equal(latest.selection?.configuration.model, 'fast', 'Agent drift cannot replace the confirmed selection'); await act(async () => { pending = latest.select({ executorId: 'external', configuration: { model: 'fast' } }); }); await render('next-draft'); await act(async () => { @@ -219,6 +425,51 @@ test('late draft discovery cannot replace the current target; existing tasks ins } }); +test('a late Agent mode update cannot replace the saved task selection', async () => { + const { document, window } = parseHTML('
'); + const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true }; + const originals = new Map(Object.keys(values).map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)])); + for (const [key, value] of Object.entries(values)) Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); + const root = createRoot(document.getElementById('root')!); + const modes = [{ id: 'ask', name: 'Ask' }, { id: 'auto', name: 'Auto' }]; + let observedMode = 'auto'; + let latest!: ReturnType; + const services = { + subscribeChanges: () => () => {}, + newTasks: { subscribeChanges: () => () => {} }, + sessions: { + getExecutorState: async () => [{ ...entry, modes, currentModel: 'selected', currentMode: observedMode }], + setExecutorModelConfiguration: async () => ({ + ok: true, + session: { executorConfig: { model: 'selected', mode: 'auto' } }, + }), + }, + } as unknown as ConversationServices; + const session = { id: 'saved', executorId: 'external', executorConfig: { model: 'selected', mode: 'ask' } } as SessionSummary; + function Probe(props: { session: SessionSummary }) { + latest = useExecutorSelection({ key: 'saved', session: props.session }); + return null; + } + try { + await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe, { session }) }))); + assert.equal(latest.entry?.currentMode, 'auto'); + assert.equal(latest.selection?.configuration.mode, 'ask', 'saved mode remains selected after Agent drift'); + await act(async () => latest.select({ executorId: 'external', configuration: { mode: 'auto' } })); + assert.equal(latest.selection?.configuration.mode, 'auto', 'confirmed change is visible before Session props catch up'); + observedMode = 'ask'; + await act(async () => latest.refresh()); + assert.equal(latest.entry?.currentMode, 'ask'); + assert.equal(latest.selection?.configuration.mode, 'auto', 'another late update cannot replace the confirmed choice'); + await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe, { session: { ...session, executorConfig: { model: 'selected', mode: 'auto' } } }) }))); + assert.equal(latest.selection?.configuration.mode, 'auto'); + await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe, { session }) }))); + assert.equal(latest.selection?.configuration.mode, 'ask', 'a later saved configuration supersedes the local confirmation'); + } finally { + await act(async () => root.unmount()); + for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); } + } +}); + test('native task creation preserves untouched, provider-default and explicit thinking choices', () => { for (const level of [undefined, null, 'high'] as const) { @@ -246,3 +497,18 @@ test('an executor choice uses its exact model without inheriting native thinking assert.equal(configuration.collaborationMode, 'agent'); assert.equal(configuration.orchestrationMode, 'default'); }); + +test('draft submission keeps the selected mode and blocks a removed catalog choice', () => { + const executorSelection = { executorId: 'external', configuration: { model: 'selected', mode: 'auto' } }; + const configuration = newTaskConfiguration({ + executorSelection, + newChatModel: null, pendingNewChatThinkingLevel: undefined, + newChatPermissionChoice: undefined, newChatCollaborationMode: 'agent', + newChatOrchestrationMode: 'default', + }); + assert.deepEqual(configuration.executorConfig, { model: 'selected', mode: 'auto' }); + assert.match(executorSubmissionError({ + executorSelection, + executorEntry: { ...entry, modes: [{ id: 'ask', name: 'Ask' }] }, + }, 0, 'en') ?? '', /no longer available/u); +}); diff --git a/apps/desktop/src/main/__tests__/external-agent-executable-selection.test.ts b/apps/desktop/src/main/__tests__/external-agent-executable-selection.test.ts new file mode 100644 index 00000000000..227c2826eb2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/external-agent-executable-selection.test.ts @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import type { OpenDialogOptions } from 'electron'; +import { selectAntigravityExecutable } from '../external-agent-executable-selection.js'; + +async function programFixture() { + const root = await mkdtemp(join(tmpdir(), 'maka-antigravity-selection-')); + const executable = join(root, 'agy_acp_server.par'); + const helper = join(root, 'localharness_external'); + await writeFile(executable, '#!/bin/sh\nexit 0\n', { mode: 0o700 }); + await writeFile(helper, '#!/bin/sh\nexit 0\n', { mode: 0o700 }); + return { root, executable, helper }; +} + +for (const selection of ['directory', 'executable'] as const) { + test(`macOS accepts the ${selection} and returns the verified executable`, async () => { + const fixture = await programFixture(); + try { + const resolved = await selectAntigravityExecutable(async (options) => { + assert.deepEqual(options.properties, ['openFile', 'openDirectory']); + return { canceled: false, filePaths: [selection === 'directory' ? fixture.root : fixture.executable] }; + }, 'darwin'); + assert.equal(resolved, await realpath(fixture.executable)); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); +} + +for (const invalid of ['missing selection', 'missing executable', 'missing helper', 'executable directory', 'helper directory', 'non-executable server', 'non-executable helper'] as const) { + test(`macOS rejects ${invalid} before returning a path to save`, { + // Windows ignores POSIX execute bits; missing-file and file-type cases still run there. + skip: process.platform === 'win32' && invalid.startsWith('non-executable'), + }, async () => { + const fixture = await programFixture(); + try { + let selected = fixture.root; + let failure = 'executable_unavailable'; + if (invalid === 'missing selection') selected = join(fixture.root, 'missing'); + if (invalid === 'missing executable' || invalid === 'executable directory') { + await rm(fixture.executable); + if (invalid === 'executable directory') await mkdir(fixture.executable); + } + if (invalid === 'missing helper' || invalid === 'helper directory') { + await rm(fixture.helper); + if (invalid === 'helper directory') await mkdir(fixture.helper); + failure = 'helper_unavailable'; + } + if (invalid === 'non-executable server') await chmod(fixture.executable, 0o600); + if (invalid === 'non-executable helper') { + await chmod(fixture.helper, 0o600); + failure = 'helper_unavailable'; + } + await assert.rejects(selectAntigravityExecutable(async () => ({ canceled: false, filePaths: [selected] }), 'darwin'), { failure }); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); +} + +test('cancelling the picker does not inspect or return a selected path', async () => { + assert.equal(await selectAntigravityExecutable(async () => ({ canceled: true, filePaths: ['/missing'] }), 'darwin'), undefined); + assert.equal(await selectAntigravityExecutable(async () => ({ canceled: false, filePaths: [] }), 'darwin'), undefined); +}); + +test('other platforms retain file-only selection', async () => { + let options: OpenDialogOptions | undefined; + const selected = await selectAntigravityExecutable(async (input) => { + options = input; + return { canceled: false, filePaths: ['/chosen/agent.exe'] }; + }, 'win32'); + assert.deepEqual(options?.properties, ['openFile']); + assert.equal(selected, '/chosen/agent.exe'); +}); diff --git a/apps/desktop/src/main/__tests__/external-agents-settings-page.test.ts b/apps/desktop/src/main/__tests__/external-agents-settings-page.test.ts index cb68ae0b8a5..45de551476d 100644 --- a/apps/desktop/src/main/__tests__/external-agents-settings-page.test.ts +++ b/apps/desktop/src/main/__tests__/external-agents-settings-page.test.ts @@ -63,6 +63,7 @@ async function mount( list?: boolean; executable?: string; remote?: boolean; + selectExecutable?: () => Promise; start?: (value: ExternalAgentSetupStart) => Promise; } = {}, ) { @@ -112,7 +113,7 @@ async function mount( }), }, externalAgents: { - selectExecutable: async () => '/existing/agy_acp_server.par', + selectExecutable: input.selectExecutable ?? (async () => '/existing/agy_acp_server.par'), start: async (value: ExternalAgentSetupStart) => { starts.push(value); return input.start ? input.start(value) : { ...value, phase: 'succeeded' }; @@ -307,6 +308,22 @@ test('saved program exposes file selection without an advanced path editor', asy assert.equal(page.starts.length, 0); }); +for (const failure of ['executable_unavailable', 'helper_unavailable'] as const) { + test(`invalid program selection reports ${failure} without changing the saved path`, async () => { + const page = await mount({ + selectExecutable: async () => { + throw new Error(`Error invoking remote method 'external-agents:select-executable': Error: Antigravity program: ${failure}`); + }, + }); + await act(async () => page.button('Choose existing program').click()); + assert.match(page.document.querySelector('[role="alert"]')?.textContent ?? '', + failure === 'helper_unavailable' ? /localharness_external helper is missing/ : /executable is missing/); + assert.deepEqual(page.updates, []); + assert.deepEqual(page.starts, []); + assert.equal(page.button('Choose existing program').disabled, false); + }); +} + test('first setup offers managed install and an official source without exposing a path editor', async () => { const page = await mount({ executable: '' }); assert.match(page.document.body.textContent ?? '', /configure its path automatically/); diff --git a/apps/desktop/src/main/__tests__/session-local.test.ts b/apps/desktop/src/main/__tests__/session-local.test.ts index 38c69d51fff..156916f5b08 100644 --- a/apps/desktop/src/main/__tests__/session-local.test.ts +++ b/apps/desktop/src/main/__tests__/session-local.test.ts @@ -900,14 +900,28 @@ test('local creation preserves a plugin executor in the pending Session projecti const summary = (await create( {} as IpcMainInvokeEvent, target.scope, - { executorId: 'codex.app-server' }, + { executorId: 'codex.app-server', executorConfig: { model: 'picked-model', mode: 'default' } }, )) as DesktopSessionSummaryInput; assert.equal(summary.backend, 'plugin-executor'); assert.equal(summary.executorId, 'codex.app-server'); assert.equal(summary.llmConnectionId, undefined); assert.equal(summary.llmConnectionSlug, 'executor:codex.app-server'); - assert.equal(summary.model, 'codex.app-server'); + assert.equal(summary.model, 'picked-model'); + assert.deepEqual(summary.executorConfig, { model: 'picked-model', mode: 'default' }); + assert.deepEqual(store.sessions(target.partition)[0]?.executorConfig, summary.executorConfig); assert.equal(store.creation(target.partition, summary.id)?.executorId, 'codex.app-server'); + const legacy = (await create( + {} as IpcMainInvokeEvent, + target.scope, + { executorId: 'codex.app-server', model: 'legacy-model' }, + )) as DesktopSessionSummaryInput; + assert.equal(legacy.model, 'legacy-model'); + const defaultModel = (await create( + {} as IpcMainInvokeEvent, + target.scope, + { executorId: 'codex.app-server' }, + )) as DesktopSessionSummaryInput; + assert.equal(defaultModel.model, 'codex.app-server'); }); test('local submit preserves picked-file approvals until durable admission succeeds', async (t) => { diff --git a/apps/desktop/src/main/external-agent-executable-selection.ts b/apps/desktop/src/main/external-agent-executable-selection.ts new file mode 100644 index 00000000000..6e3e3e6fc3f --- /dev/null +++ b/apps/desktop/src/main/external-agent-executable-selection.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import type { OpenDialogOptions, OpenDialogReturnValue } from 'electron'; +import { checkAntigravityProgram, AntigravityProgramError } from '@maka/runtime-host/antigravity-program'; + +export async function selectAntigravityExecutable( + showOpenDialog: (options: OpenDialogOptions) => Promise, + platform: NodeJS.Platform = process.platform, +): Promise { + const result = await showOpenDialog({ + // A directory remains selectable when macOS disables the Mach-O binary. + properties: platform === 'darwin' ? ['openFile', 'openDirectory'] : ['openFile'], + }); + const selected = result.canceled ? undefined : result.filePaths[0]; + if (!selected || platform !== 'darwin') return selected; + const selection = await stat(selected).catch(() => { + throw new AntigravityProgramError('executable_unavailable'); + }); + const candidate = selection.isDirectory() ? join(selected, 'agy_acp_server.par') : selected; + const { executable } = await checkAntigravityProgram(candidate); + return executable; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index aaa0d4ca4a2..78f589533bc 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -238,6 +238,7 @@ import { createDesktopRuntimeHostManagement } from "./runtime-host-management.js import { createDesktopRuntimeHostLocalManagement } from './runtime-host-local-management.js'; import { createDesktopRuntimeHostPeerMeshManagement } from './runtime-host-peer-mesh-management.js'; import { registerExternalAgentSetupIpc } from "./external-agent-setup-ipc-main.js"; +import { selectAntigravityExecutable } from './external-agent-executable-selection.js'; import { registerRuntimeHostOAuthIpc } from "./runtime-host-oauth-ipc-main.js"; import { RuntimeHostOAuthPresentation } from "./runtime-host-oauth-presentation.js"; import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ipc-main.js"; @@ -1624,10 +1625,9 @@ function registerHostClientIpc( }); registerExternalAgentSetupIpc({ ipcMain: scopedIpc, client, presentation: oauthPresentation, onCatalogChanged: () => sendToRenderer('external-agents:catalog-changed'), - selectExecutable: async () => { - const result = await mainWindowController.showOpenDialog({ properties: ['openFile'] }); - return result.canceled ? undefined : result.filePaths[0]; - }, + selectExecutable: () => selectAntigravityExecutable( + (options) => mainWindowController.showOpenDialog(options), + ), }); registerRuntimeHostOAuthIpc({ ipcMain: scopedIpc, diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index f2d455805b4..4ce9a437912 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -117,9 +117,10 @@ export function registerRuntimeHostSessionCatalogIpc( const actionIds = (sessionId: string, options: unknown) => resolveSessionActionIds(() => listSessions(), sessionId, options); - handleReconnectableRead(ipcMain, 'sessions:executorCatalog', async (_event, cwd: string) => { + handleReconnectableRead(ipcMain, 'sessions:executorCatalog', async (_event, cwd: string, refresh?: boolean) => { if (typeof cwd !== 'string' || !cwd) throw new Error('Executor discovery requires a workspace'); - return (await deps.queryExecutors?.({ kind: 'catalog', cwd }))?.items ?? []; + if (refresh !== undefined && typeof refresh !== 'boolean') throw new Error('Invalid executor refresh flag'); + return (await deps.queryExecutors?.({ kind: 'catalog', cwd, ...(refresh ? { refresh: true } : {}) }))?.items ?? []; }); handleReconnectableRead(ipcMain, 'sessions:executorState', async (_event, sessionId: string) => (await deps.queryExecutors?.({ kind: 'conversation', sessionId }))?.items ?? [], diff --git a/apps/desktop/src/main/session-local-service.ts b/apps/desktop/src/main/session-local-service.ts index 08b41485d63..ac5241e7d20 100644 --- a/apps/desktop/src/main/session-local-service.ts +++ b/apps/desktop/src/main/session-local-service.ts @@ -673,7 +673,8 @@ export function registerDesktopSessionLocalIpc(deps: { ? { executorId: creation.executorId, llmConnectionSlug: `executor:${creation.executorId}`, - model: creation.executorId, + model: creation.executorConfig?.model ?? creation.executorModel ?? creation.executorId, + ...(creation.executorConfig ? { executorConfig: creation.executorConfig } : {}), } : { llmConnectionSlug: input.llmConnectionSlug ?? '', diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3868b8e3f5f..52c50ef9815 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1006,7 +1006,7 @@ export interface MakaBridge { }; newTasks: { - getExecutors(target: DesktopNewTaskTarget, cwd: string): Promise; + getExecutors(target: DesktopNewTaskTarget, cwd: string, refresh?: boolean): Promise; getCatalog(): Promise; subscribeChanges(handler: () => void): () => void; addProject(host: DesktopNewTaskHostRef, name?: string): Promise< diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index d91119570a1..eb4c9e8885a 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1954,8 +1954,8 @@ const makaBridge = { }, }, newTasks: { - async getExecutors(target, cwd) { - return ipcRenderer.invoke('sessions:executorCatalog', await runtimeHostScope(target), cwd); + async getExecutors(target, cwd, refresh) { + return ipcRenderer.invoke('sessions:executorCatalog', await runtimeHostScope(target), cwd, refresh); }, getCatalog(): Promise { return loadNewTaskCatalog(); diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 5b136e2ca31..cbe2af5a326 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -545,6 +545,7 @@ function AppShellContent({ newTaskKey: currentNewTaskDraftKey, executorTarget: taskEntry.selectors.target, executorCwd: activeSession?.cwd ?? taskEntry.selectors.projectPath, + executorSessionPending: activeSession?.localState === 'pending', activationCandidate: modelSettingsOwnsComposerHost ? onboardingActivationCandidate : undefined, @@ -1230,11 +1231,7 @@ function AppShellContent({ captureOwner: captureComposerImportOwner, isOwnerActive: isShellSurfaceOwnerActive, isNewChatOwnerActive: isNewChatSendSurfaceActive, - activateFirstSendSession: async (session) => { - commitSession(session); - setNavSelection({ section: 'sessions' }); - setActiveId(session.id); - }, + activateFirstSendSession: Conversation.createExecutorSessionActivator(executor, commitSession, setNavSelection, setActiveId), openSession: openSessionInChat, retireSession: clearSessionRendererState, refreshSessions, diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts b/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts index 587594f3d88..8b0f517c15b 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts @@ -18,7 +18,7 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import type { ExecutorCatalogEntry, ExecutorSelection } from '@maka/core/executor-catalog'; +import type { ExecutorCatalogEntry, ExecutorConfiguration, ExecutorSelection } from '@maka/core/executor-catalog'; import type { SessionSummary } from '@maka/core/session'; import type { ConversationNewTaskTarget } from '../ports.js'; import { useConversationServices } from '../services.js'; @@ -28,6 +28,8 @@ export function useExecutorSelection(input: { target?: ConversationNewTaskTarget; cwd?: string; session?: SessionSummary; + /** Local creation has not yet materialized a Session on the Host. */ + sessionPending?: boolean; }) { const services = useConversationServices(); const [draft, setDraft] = useState<{ key: string; selection?: ExecutorSelection }>(); @@ -37,19 +39,38 @@ export function useExecutorSelection(input: { loading: boolean; error?: string; }>(); + const handoff = useRef(undefined); const [changingKey, setChangingKey] = useState(); + const [confirmed, setConfirmed] = useState<{ + key: string; + previous?: ExecutorConfiguration; + configuration: ExecutorConfiguration; + }>(); const inFlight = useRef(undefined); const sessionId = input.session?.id; const executorId = input.session?.executorId; - const key = sessionId ?? input.key; + const key = sessionId ?? JSON.stringify([ + input.key, + input.target?.hostId, + input.target?.profileId, + input.target?.projectId, + input.cwd, + ]); const current = useRef(key); current.current = key; const revision = useRef(0); const refreshes = useRef(new Map>()); const pendingInvalidations = useRef(new Set()); - const refresh = useCallback((): Promise => { + const pendingForce = useRef(new Set()); + const refresh = useCallback((force = false): Promise => { const existing = refreshes.current.get(key); - if (existing) return existing; + if (existing) { + if (force) { + pendingInvalidations.current.add(key); + pendingForce.current.add(key); + } + return existing; + } const run = (async () => { const attempt = ++revision.current; if (sessionId && !executorId) { @@ -57,17 +78,38 @@ export function useExecutorSelection(input: { return; } if (!sessionId && (!input.target || !input.cwd)) return; - setSnapshot((previous) => ({ - key, - catalog: previous?.key === key ? previous.catalog : [], - loading: true, - })); + const inherited = handoff.current?.key === key ? handoff.current : undefined; + setSnapshot((previous) => { + const seed = previous?.key === key ? previous : inherited; + return { key, catalog: seed?.catalog ?? [], loading: true }; + }); + if (sessionId && input.sessionPending) return; try { const catalog = sessionId ? ((await services.sessions.getExecutorState?.(sessionId)) ?? []) - : ((await services.newTasks.getExecutors?.(input.target!, input.cwd!)) ?? []); + : ((await services.newTasks.getExecutors?.(input.target!, input.cwd!, force)) ?? []); if (current.current === key && revision.current === attempt) - setSnapshot({ key, catalog, loading: false }); + setSnapshot(previous => { + const seed = previous?.key === key ? previous : inherited; + return { + key, loading: false, + catalog: catalog.map(entry => { + const known = seed?.catalog.find(candidate => candidate.id === entry.id); + // Inspection can expose saved IDs while initialization or a + // configuration update is pending. Reuse this Session's known + // presentation, independently of its observed mode capability. + if (!known || known.readiness !== 'ready' || entry.readiness !== 'ready' || + entry.supportsModelChange || !entry.models.every(model => model.name === model.id)) + return entry; + return { ...entry, displayName: known.displayName, models: known.models, + modelGroups: known.modelGroups, + ...(!entry.supportsModeChange ? { + modes: known.modes, currentMode: entry.currentMode ?? known.currentMode, + } : {}), + }; + }), + }; + }); } catch (error) { if (current.current === key && revision.current === attempt) setSnapshot({ @@ -82,7 +124,8 @@ export function useExecutorSelection(input: { tracked = run.finally(() => { if (refreshes.current.get(key) !== tracked) return; refreshes.current.delete(key); - if (pendingInvalidations.current.delete(key) && current.current === key) void refresh(); + const forced = pendingForce.current.delete(key); + if (pendingInvalidations.current.delete(key) && current.current === key) void refresh(forced); }); refreshes.current.set(key, tracked); return tracked; @@ -90,7 +133,9 @@ export function useExecutorSelection(input: { key, sessionId, executorId, + input.sessionPending, input.session?.executorConfig?.model, + input.session?.executorConfig?.mode, input.target?.hostId, input.target?.profileId, input.target?.projectId, @@ -115,37 +160,67 @@ export function useExecutorSelection(input: { await refresh(); if (!stopped) timer = setTimeout(() => void poll(), 3000); }; - if (sessionId && executorId) void poll(); + if (sessionId && executorId && !input.sessionPending) void poll(); else void refresh(); return () => { stopped = true; revision.current++; refreshes.current.delete(key); pendingInvalidations.current.delete(key); + pendingForce.current.delete(key); unsubscribe(); unSession(); clearTimeout(timer); }; - }, [refresh, invalidate, services, sessionId, executorId, key]); + }, [refresh, invalidate, services, sessionId, executorId, key, input.sessionPending]); useEffect(() => { if (sessionId) setDraft(undefined); }, [sessionId]); - const catalog = snapshot?.key === key ? snapshot.catalog : []; + useEffect(() => { + // Retire a handoff only after its replacement has committed. A superseded + // request must not erase it before the next inspection can inherit it. + if (snapshot && handoff.current?.key === snapshot.key) handoff.current = undefined; + }, [snapshot]); + useEffect(() => { + if (confirmed?.key === key && + confirmed.configuration.model === input.session?.executorConfig?.model && + confirmed.configuration.mode === input.session?.executorConfig?.mode) + setConfirmed(undefined); + }, [key, confirmed, input.session?.executorConfig?.model, input.session?.executorConfig?.mode]); + const currentSnapshot = snapshot?.key === key ? snapshot : handoff.current?.key === key ? handoff.current : undefined; + const catalog = currentSnapshot?.catalog ?? []; const inspected = catalog.find(candidate => candidate.id === executorId); + // The catalog describes observed Agent state. The saved Session config is the + // selection that will be applied before the next prompt. + const savedConfiguration = confirmed?.key === key && + confirmed.previous?.model === input.session?.executorConfig?.model && + confirmed.previous?.mode === input.session?.executorConfig?.mode + ? confirmed.configuration + : { + ...(input.sessionPending && executorId && input.session?.model && input.session.model !== executorId + ? { model: input.session.model } + : {}), + ...input.session?.executorConfig, + }; const selection = executorId - ? { executorId, configuration: inspected?.readiness === 'ready' && inspected.currentModel - ? { model: inspected.currentModel } : input.session?.executorConfig ?? {} } + ? { executorId, configuration: inspected?.readiness === 'ready' + ? { ...(inspected.currentModel ? { model: inspected.currentModel } : {}), + ...(inspected.currentMode ? { mode: inspected.currentMode } : {}), + ...savedConfiguration } + : savedConfiguration ?? {} } : sessionId ? undefined - : draft?.key === input.key + : draft?.key === key ? draft.selection : undefined; const select = async (next: ExecutorSelection | undefined) => { if (inFlight.current === key) throw new Error('Executor configuration is pending'); if (!sessionId) { if (next && !catalog.some(entry => entry.id === next.executorId && entry.readiness === 'ready' && - entry.models.some(model => model.id === next.configuration.model))) throw new Error('Executor model is unavailable'); - setDraft({ key: input.key, selection: next }); + (!next.configuration.model || entry.models.some(model => model.id === next.configuration.model)) && + (!next.configuration.mode || entry.modes?.some(mode => mode.id === next.configuration.mode)))) + throw new Error('Executor configuration is unavailable'); + setDraft({ key, selection: next }); return; } if (!next || next.executorId !== executorId || !services.sessions.setExecutorModelConfiguration) @@ -158,14 +233,20 @@ export function useExecutorSelection(input: { next.configuration, ); if (!result.ok) throw new Error(result.code); - if (result.session.executorConfig?.model !== next.configuration.model) - throw new Error('Executor model change was not confirmed'); + if ((next.configuration.model && result.session.executorConfig?.model !== next.configuration.model) || + (next.configuration.mode && result.session.executorConfig?.mode !== next.configuration.mode)) + throw new Error('Executor configuration change was not confirmed'); if (current.current === key) { + setConfirmed({ + key, + previous: input.session?.executorConfig, + configuration: result.session.executorConfig ?? {}, + }); revision.current++; setSnapshot(previous => ({ key, loading: false, catalog: (previous?.key === key ? previous.catalog : []).map(entry => entry.id === executorId - ? { ...entry, currentModel: result.session.executorConfig!.model } : entry), + ? { ...entry, currentModel: result.session.executorConfig?.model, currentMode: result.session.executorConfig?.mode } : entry), })); await refresh(); } @@ -185,11 +266,31 @@ export function useExecutorSelection(input: { } }; const entry = catalog.find((candidate) => candidate.id === selection?.executorId); + const adoptSession = (session: SessionSummary) => { + if (sessionId || !selection || !entry || session.executorId !== selection.executorId) return; + // Only a submitted draft hands its catalog to the new Session. Navigation + // to another task must still inspect that task's own executor state. + revision.current++; + const initial = { ...entry, + currentModel: session.executorConfig?.model ?? selection.configuration.model ?? entry.currentModel, + currentMode: session.executorConfig?.mode ?? selection.configuration.mode ?? entry.currentMode, + }; + handoff.current = { key: session.id, catalog: [initial], loading: true }; + }; const restore = async () => { if (!sessionId || !executorId) throw new Error('Executor Session is unavailable'); if (inFlight.current === key) throw new Error('Executor configuration is pending'); - const model = input.session?.executorConfig?.model ?? inspected?.currentModel; - if (!model) throw new Error('Executor model is unavailable'); + let configuration = input.session?.executorConfig ?? { + ...(inspected?.currentModel ? { model: inspected.currentModel } : {}), + ...(inspected?.currentMode ? { mode: inspected.currentMode } : {}), + }; + // A failed restore exposes the last observed model capabilities. Let an + // explicit retry recover from a removed mode without weakening prompt validation. + if (inspected?.readiness === 'restore_failed' && configuration.model && configuration.mode && + inspected.supportsModelChange && inspected.currentModel === configuration.model && + (inspected.supportsModeChange === false || (inspected.supportsModeChange === true && + !inspected.modes?.some(mode => mode.id === configuration.mode)))) + configuration = { model: configuration.model }; setSnapshot((previous) => previous?.key === key ? { @@ -200,7 +301,7 @@ export function useExecutorSelection(input: { } : previous, ); - await select({ executorId, configuration: { model } }); + await select({ executorId, configuration }); }; return { selection, @@ -209,8 +310,9 @@ export function useExecutorSelection(input: { select, restore, refresh, + adoptSession, changing: changingKey === key, - loading: snapshot?.key !== key || snapshot.loading, - error: snapshot?.key === key ? snapshot.error : undefined, + loading: !currentSnapshot || currentSnapshot.loading, + error: currentSnapshot?.error, }; } diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts b/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts index 35c48187114..57e28013924 100644 --- a/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts +++ b/apps/desktop/src/renderer/features/conversation/controller/use-shell-chat-model.ts @@ -86,6 +86,7 @@ export function useShellChatModel(options: { newTaskKey: string; executorTarget?: import('../ports.js').ConversationNewTaskTarget; executorCwd?: string; + executorSessionPending?: boolean; activationCandidate?: NewChatModelCandidate; activeSession: SessionSummary | undefined; sessionHealthSession: SessionSummary | undefined; @@ -124,7 +125,7 @@ export function useShellChatModel(options: { onExecutorTargetChange: (target: MakaClientExecutorTarget) => Promise; sessionHealthNotice: SessionHealthNoticeView | undefined; } { - const executor = useExecutorSelection({ key: options.newTaskKey, target: options.executorTarget, cwd: options.executorCwd, session: options.activeSession }); + const executor = useExecutorSelection({ key: options.newTaskKey, target: options.executorTarget, cwd: options.executorCwd, session: options.activeSession, sessionPending: options.executorSessionPending }); const { uiLocale, connections, diff --git a/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts b/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts index 9be292d6207..3a9f651193a 100644 --- a/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts +++ b/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts @@ -25,6 +25,7 @@ export function executorComposerProps( input: { activeId: string | undefined; turnActive: boolean; + sendPending?: boolean; taskSubmissionHardBlocked: boolean; connectionCount: number; onSetup(): void; @@ -39,13 +40,13 @@ export function executorComposerProps( catalog: executor.catalog, selection: executor.selection, fixed, - disabled: executor.changing || (fixed && input.turnActive), + disabled: executor.changing || !!input.sendPending || (fixed && input.turnActive), loading: executor.loading, error: executor.error, onSelect: (selection) => executor.select(selection), onRestore: () => executor.restore(), onRetry: () => { - void executor.refresh(); + void executor.refresh(true); }, onSetup: input.onSetup, onNewTask: input.onNewTask, @@ -54,6 +55,7 @@ export function executorComposerProps( noModelConnection: !executor.selection && !fixed && input.connectionCount === 0, sendBlocked: input.taskSubmissionHardBlocked || + !!input.sendPending || !!( executor.selection && (executor.entry?.readiness !== 'ready' || (fixed && input.turnActive)) diff --git a/apps/desktop/src/renderer/features/conversation/model/executor-submission.ts b/apps/desktop/src/renderer/features/conversation/model/executor-submission.ts index ce9c474e1ab..9b2cd59ddfe 100644 --- a/apps/desktop/src/renderer/features/conversation/model/executor-submission.ts +++ b/apps/desktop/src/renderer/features/conversation/model/executor-submission.ts @@ -24,39 +24,60 @@ import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ThinkingLevel } from '@maka/core/model-thinking'; import type { UiCatalog, UiLocale } from '@maka/core/ui-locale'; import type { NewChatModel } from './shell-chat-model-selection.js'; +import type { SessionSummary } from '@maka/core/session'; + +/** Adopt the submitted catalog before the locally created Session becomes visible. */ +export function createExecutorSessionActivator( + executor: { adoptSession(session: SessionSummary): void }, + commitSession: (session: Session) => void, + selectNavigation: (selection: { section: 'sessions' }) => void, + activateSession: (sessionId: string) => void, +): (session: Session) => Promise { + return async session => { + executor.adoptSession(session); + commitSession(session); + selectNavigation({ section: 'sessions' }); + activateSession(session.id); + }; +} export interface ExecutorSubmission { executorSelection?: ExecutorSelection; - executorEntry?: Pick; + executorEntry?: Pick & + Partial>; } const SUBMISSION_COPY = { en: { attachments: 'Remove unsupported attachments or select Maka. Your draft is preserved.', unavailable: 'Check External Agents settings or start a new task.', + invalid: 'The selected Agent configuration is no longer available. Choose again.', }, 'zh-CN': { attachments: '请移除不支持的附件或选择 Maka,草稿会保留。', unavailable: '请检查外部 Agent 设置,或新建任务。', + invalid: '所选 Agent 配置已失效,请重新选择。', }, 'zh-TW': { attachments: '請移除不支援的附件或選擇 Maka,草稿會保留。', unavailable: '請檢查外部 Agent 設定,或建立新任務。', + invalid: '所選 Agent 設定已失效,請重新選擇。', }, -} satisfies UiCatalog<{ attachments: string; unavailable: string }>; +} satisfies UiCatalog<{ attachments: string; unavailable: string; invalid: string }>; export function executorSubmissionError( input: ExecutorSubmission, attachments: number, locale: UiLocale, ): string | undefined { - if ( - !input.executorSelection || - (input.executorEntry?.readiness === 'ready' && - (!attachments || input.executorEntry.supportsAttachments)) - ) - return; - return SUBMISSION_COPY[locale][attachments ? 'attachments' : 'unavailable']; + if (!input.executorSelection) return; + const entry = input.executorEntry; + if (!entry || entry.readiness !== 'ready') return SUBMISSION_COPY[locale].unavailable; + if (attachments && !entry.supportsAttachments) return SUBMISSION_COPY[locale].attachments; + const { model, mode } = input.executorSelection.configuration; + if ((model && entry.models && !entry.models.some(candidate => candidate.id === model)) || + (mode && !entry.modes?.some(candidate => candidate.id === mode))) + return SUBMISSION_COPY[locale].invalid; } export function newTaskConfiguration( diff --git a/apps/desktop/src/renderer/features/conversation/ports.ts b/apps/desktop/src/renderer/features/conversation/ports.ts index d85d5a61d89..1e735b8a5bb 100644 --- a/apps/desktop/src/renderer/features/conversation/ports.ts +++ b/apps/desktop/src/renderer/features/conversation/ports.ts @@ -109,7 +109,7 @@ export interface ConversationServices extends Pick< ): Promise; }; readonly newTasks: { - getExecutors?(target: ConversationNewTaskTarget, cwd: string): Promise; + getExecutors?(target: ConversationNewTaskTarget, cwd: string, refresh?: boolean): Promise; subscribeChanges(handler: () => void): () => void; listInvocableSkills( target: ConversationNewTaskTarget, diff --git a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx index 1410ba2fbb3..e2b8eb9849e 100644 --- a/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx +++ b/apps/desktop/src/renderer/features/conversation/ui/conversation-readers.tsx @@ -18,7 +18,7 @@ */ import { createElement, useMemo, useSyncExternalStore, type ComponentType, type ComponentProps } from 'react'; -import { ChatView, useUiLocale } from '@maka/ui'; +import { ChatView, useUiLocale, type ComposerProps as UiComposerProps } from '@maka/ui'; import type { StoredMessage } from '@maka/core/session'; import type { SessionUiReads } from '../model/session-ui-reads.js'; import { useSessionUiRead } from '../controller/use-session-ui-read.js'; @@ -77,7 +77,8 @@ type ComposerProps = SubmissionProps & { processing: boolean; pendingMessages: ChatProps['transientMessages']; latestRequestUsageTokens?: number; }; /** The shell's picker gates; an edit-and-resend draft narrows them here. */ -type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean }; +type ComposerPickGates = { contextPickEnabled?: boolean; directoryPickerEnabled?: boolean } & + Pick; /** * Lives in the persistent composer slot, outside the conditional transcript. * Submission state (send pending, Stop pending, the edit-and-resend draft) and @@ -102,6 +103,10 @@ export function ConversationComposerRegion

( const owned: ComposerProps & ComposerPickGates = { onSend: submission.onSend, newTaskSendPending: submission.newTaskSendPending, + sendBlocked: gates.sendBlocked || submission.newTaskSendPending, + ...(gates.executorPicker ? { + executorPicker: { ...gates.executorPicker, disabled: gates.executorPicker.disabled || submission.newTaskSendPending }, + } : {}), onStop: submission.stop, stop: submission.stop, stopPending, diff --git a/apps/desktop/src/renderer/features/external-agent-settings/page.tsx b/apps/desktop/src/renderer/features/external-agent-settings/page.tsx index e8d40aabd30..4fe687d0031 100644 --- a/apps/desktop/src/renderer/features/external-agent-settings/page.tsx +++ b/apps/desktop/src/renderer/features/external-agent-settings/page.tsx @@ -110,7 +110,7 @@ function AntigravitySetup(props: Props & { onBack(): void }) { const [availabilityRetry, setAvailabilityRetry] = useState(0); const [projection, setProjection] = useState(); const [busy, setBusy] = useState(false); - const [error, setError] = useState(false); + const [error, setError] = useState(false); const guard = useActionGuard(); const attempt = useRef(undefined); const mounted = useRef(false); @@ -158,8 +158,14 @@ function AntigravitySetup(props: Props & { onBack(): void }) { setConnectionVerified(false); setProjection(undefined); await props.onUpdate({ externalAgents: { antigravity: { executable: selected } } }); - } catch { - if (mounted.current) setError(true); + } catch (selectionError) { + if (mounted.current) { + // Electron preserves Error messages, including its own IPC prefix. + const failure = selectionError instanceof Error + ? selectionError.message.match(/Antigravity program: (executable_unavailable|helper_unavailable)\b/)?.[1] + : undefined; + setError(failure === 'executable_unavailable' || failure === 'helper_unavailable' ? failure : true); + } } finally { if (mounted.current) setBusy(false); guard.finish(); @@ -226,7 +232,7 @@ function AntigravitySetup(props: Props & { onBack(): void }) { } } const status = error - ? copy.error + ? typeof error === 'string' ? copy.failures[error] : copy.error : available === undefined ? copy.loading : !available diff --git a/apps/desktop/src/renderer/locales/settings-external-agents-copy.ts b/apps/desktop/src/renderer/locales/settings-external-agents-copy.ts index 8d007cfa861..2ddf6418344 100644 --- a/apps/desktop/src/renderer/locales/settings-external-agents-copy.ts +++ b/apps/desktop/src/renderer/locales/settings-external-agents-copy.ts @@ -26,7 +26,7 @@ const en = { installHelp: "Download the official program from Google and configure its path automatically.", programConfigured: "A program path is saved. Check the connection to verify it is available.", selectExisting: "Choose existing program", - existingProgramHelp: "Already have ACP? Choose your existing program to use its local path without reinstalling.", + existingProgramHelp: "Already have ACP? On macOS, choose agy_acp_server.par or its extracted folder. Keep localharness_external beside the executable.", reinstall: "Reinstall", source: "Download source", release: "Official download: v1.1.1 · macOS Apple Silicon · 316 MB", @@ -90,7 +90,7 @@ const zh = { installHelp: "从 Google 下载官方程序,安装后自动配置,无需填写路径。", programConfigured: "已保存程序位置,可检查连接以确认程序可用。", selectExisting: "选择已有程序", - existingProgramHelp: "已有 ACP 程序?点击“选择已有程序”,使用本机路径,无需重新安装。", + existingProgramHelp: "已有 ACP 程序?在 macOS 上选择 agy_acp_server.par 或其解压目录,并将 localharness_external 保留在程序旁。", reinstall: "重新安装", source: "下载来源", release: "官方下载:v1.1.1 · macOS Apple 芯片 · 316 MB", @@ -149,7 +149,7 @@ const zhTW: typeof en = { installHelp: "從 Google 下載官方程式,安裝後自動設定,無需填寫路徑。", programConfigured: "已儲存程式位置,可檢查連線以確認程式可用。", selectExisting: "選擇已有程式", - existingProgramHelp: "已有 ACP 程式?點擊「選擇已有程式」,使用本機路徑,無需重新安裝。", + existingProgramHelp: "已有 ACP 程式?在 macOS 上選擇 agy_acp_server.par 或其解壓目錄,並將 localharness_external 保留在程式旁。", reinstall: "重新安裝", source: "下載來源", release: "官方下載:v1.1.1 · macOS Apple 晶片 · 316 MB", diff --git a/docs/antigravity-acp-plugin-rebuild.md b/docs/antigravity-acp-plugin-rebuild.md index edc33aa862e..6a6b1f08936 100644 --- a/docs/antigravity-acp-plugin-rebuild.md +++ b/docs/antigravity-acp-plugin-rebuild.md @@ -157,11 +157,13 @@ This does not introduce a new application architecture or another backend. ## Scope and acceptance -PR 2 covers local macOS arm64 Desktop and local Runtime Host. PR 3 alone will restore an external -Session after process loss. PR 4 owns modes, account/directory invalidation and the expanded catalog -lifecycle. Remote execution, OAuth forwarding, external child orchestration, steering, rollback and +PR 2 covers local macOS arm64 Desktop and local Runtime Host. PR 3 added explicit restoration of the +same external Session after process loss, with a visible history gap when replay cannot be aligned. +PR 4 extends the generic executor configuration with opaque Agent modes and scopes catalog caches +by workspace. A successful setup/login or configuration change invalidates provider discovery; +manual refresh requests a new probe for the selected workspace. Remote execution, OAuth forwarding, external child orchestration, steering and cross-Agent continuation are not added here. -See [PR 2 acceptance evidence](archive/antigravity-acp-pr2-acceptance.md) for controlled-process coverage, -official Agent verification, Desktop verification and the remaining merge gate. The issue's PR 2 -checkbox stays unchecked until the PR is reviewed and merged. +See [PR 2 acceptance evidence](archive/antigravity-acp-pr2-acceptance.md), +[PR 3 acceptance evidence](archive/antigravity-acp-pr3-acceptance.md), and +[PR 4 acceptance evidence](archive/antigravity-acp-pr4-acceptance.md) for the checks and their limits. diff --git a/docs/archive/antigravity-acp-pr4-acceptance.md b/docs/archive/antigravity-acp-pr4-acceptance.md new file mode 100644 index 00000000000..1b299e51a44 --- /dev/null +++ b/docs/archive/antigravity-acp-pr4-acceptance.md @@ -0,0 +1,411 @@ + + +# Antigravity ACP PR 4 acceptance + +PR 4 follows [issue #5103](https://github.com/apache/maka/issues/5103). +This record separates controlled protocol fixtures from official Agent results. + +## P3 stable-directory and invalidation follow-up, 2026-10-02 + +Tested implementation commit: `4fefdc1e7fae53bcc2e8412c03be5eadd9e0de4b`. +Registered ACP adapters now use stable Plugin-storage scratch paths, keyed by the +Host data root, Plugin namespace, adapter ID and composition Entry. The Host reuses +its existing native file lifetime leases; no additional native dependency is bundled +into the ACP Plugin. Leases cover Agent process disposal and directory cleanup. +Standalone `AcpExecutor` embeddings reuse a temporary path within their lifetime; +a durable embedding must provide `withCatalogDirectory`. + +The official Agent 1.2.1 recheck used the production executor, transport and +Host scratch-directory implementation. A transparent request observer recorded +three actual `session/new` requests: initial discovery, explicit refresh and discovery +after disposing/recreating the executor and its Host data runtime. All three requests +used the same canonical neutral path; each started empty and its directory was removed +after discovery returned. The shared A/B cache and refreshed replacement snapshot +checks passed. All catalogs were ready and contained the same 11 models and three modes +listed in the preceding-head record below. The two toy project directories stayed empty. + +A second check installed the actual production ACP/Antigravity Plugin bundles through +`HostBuiltinExternalAgentPluginCoordinator` and queried `PluginExecutorService` against +that official executable. Initial A/B queries opened one probe; a forced refresh opened +one replacement. Both probes used the same leased path, with namespace +`antigravity-acp` / `profile`, and both started empty and completed cleanup. No protocol +fixture or mock Agent was used in these official checks. No task prompt was sent. + +Regression coverage passed 286 tests without skips or failures: 97 ACP/Antigravity +and 189 affected Runtime/Host tests. It includes multi-waiter invalidation without a +refresh query, repeated invalidations, cancellation/disposal, non-retryable genuine +failures, stable paths across runtime replacement, namespace/key separation, directory +cleanup on callback failure, public Plugin storage namespace propagation, competing +runtime instances and a competing child process killed while holding a native lease. +Removing the revision fix makes the invalidation regression fail; restoring random +per-probe paths makes the stable-path regression fail. Positive tests pass with both +fixes restored. + +The complete workspace build, typecheck, lint, format, renderer architecture, locale +hygiene, ASF headers, Windows skip inventory and diff checks passed. Full workspace +unit/e2e tests were not repeated locally. Official observer logs and JSON results are +retained in the local run directory `pr5826-p3-acceptance-2026-10-02`; the tested source +commit is recorded in the JSON result. + +This check does not inspect or delete Agent-private history and does not establish +that its native UI groups history by cwd. Stable cwd eliminates per-probe path churn; +the Agent may still keep empty Sessions. Desktop steps 1–4 below were tested on the +preceding implementation, and steps 5–9 remain historical execution/recovery evidence, +not fresh Desktop acceptance for this P3 follow-up. + +## Preceding-head neutral probe and Desktop acceptance, 2026-10-02 + +The verification gate for the neutral-directory catalog follow-up passed on exact code +head `82d6b12b695a2967f2e907915afb817ff5087f9b`. This run covers acceptance steps 1–4; +prompt execution and restart steps 5–9 were not repeated and remain historical evidence +for the earlier builds recorded below. + +Environment: macOS arm64, Node 24.19.0, Electron 43.4.1 and official Agent 1.2.1. +The server SHA-256 was `c93c86c0f505fcdf8b13c695bed26d306141ef5446189d591397074d324db34e`; +the helper SHA-256 was `1b8a2b712ca312c9769e425b800bfbcceec4770f19736404474d1e8e50d65456`. +Desktop was freshly built from this head and launched as an ad-hoc-signed `Maka Dev.app` +in the dedicated profile alias `neutral-acceptance-2026-10-02`. Existing Agent authentication +and network settings were retained. Only empty disposable projects were selected. + +The production `AcpExecutor` used the official server and helper, with a transparent +request observer that delegated every request and cleanup to the real connection. +Both initial discovery and refresh initialized Agent version 1.2.1, protocol 1, and +returned `ready`. Actual `session/new` requests used empty neutral temporary directories +with suffixes `maka-acp-catalog-BqUc4Q` and `maka-acp-catalog-TirUh5`; neither used project A +or B. Both directories were absent when the respective discovery returned, and both +project directories remained unchanged. The initial A/B queries returned the same catalog +object; refreshing A replaced it, and the next B query returned that replacement object. +Exactly two probe Sessions were opened by this sequence. + +Both catalogs contained these model IDs: +`gemini-3.8-flash-high`, `gemini-3.8-flash-medium`, `gemini-3.8-flash-low`, +`gemini-3.7-flash-high`, `gemini-3.7-flash-medium`, `gemini-3.7-flash-low`, +`gemini-3.6-flash-high`, `gemini-3.6-flash-medium`, `gemini-3.6-flash-low`, +`gemini-pro-agent`, and `gemini-3.1-pro-low`. +Mode IDs were `default`, `auto_edit`, and `yolo`; the probe default was +`gemini-3.8-flash-high` / `default`. + +| Desktop step | Result | Observed evidence | +| --- | --- | --- | +| 1. Configure Agent | PASS | The real macOS picker accepted both the extracted directory and `agy_acp_server.par`. Missing-helper and missing-server directories produced their respective localized errors. The saved runtime-policy executable remained the original official path, and connection and Google sign-in verification succeeded afterwards. | +| 2. Discover B | PASS | Both empty projects were added through the native folder picker. B showed Gemini 3.8/3.7/3.6 Flash and Gemini 3.1 Pro families, plus Default/Auto Edit/YOLO. B selected Gemini 3.7 Flash, High, Default. | +| 3. Refresh A | PASS | B → A succeeded. Retry entered its loading state and completed with the model candidates still available. A selected Gemini 3.8 Flash, High, Auto Edit. | +| 4. Return to B | PASS | A → B succeeded without pressing Retry in B. B still offered the same model families and three modes. The separate production-executor check establishes shared cache identity and neutral paths; the UI alone does not establish either. | + +The fresh workspace build, typecheck, lint, format, renderer architecture, locale hygiene, +ASF headers and diff checks passed. ACP/Antigravity tests passed 93/93, and Desktop picker +and settings tests passed 28/28, with no skips or failures. The full workspace tests were +not repeated locally; the [exact-head hosted CI test](https://github.com/apache/maka/actions/runs/36975936091/job/110739653677) +completed successfully. This run does not establish signed-distribution qualification. + +The observer script, request log, JSON results, Desktop log, profile and local gate logs +are retained under the local run directory `pr5826-neutral-acceptance-2026-10-02`. +At that head, the stable-directory suggestion was still open. The P3 follow-up above +supersedes the random-path behavior; the Agent-native empty Session/history caveat remains. + +## Execution recheck, 2026-10-01 + +The earlier eligibility HTTP 403 no longer reproduces in this recheck. Official +Agent 1.2.1 completed synthetic prompts through the production `AcpExecutor` in +both disposable directories. The subsequent same-profile Desktop run also completed +both project prompts, idle configuration changes, restart recall and continuity checks. + +- Platform: macOS arm64; Node.js 24.19.0; official Agent server/helper SHA-256 + values match the 1.2.1 distribution recorded below. Existing authentication and + network settings were retained. No private project contents were sent. +- Initial tested code: `a950ce187c7510a762bc2e5c95acf1e63a16b888`. The full + official-Agent sequence was repeated after building merged code head + `ccd3c195e94bb262df0ef862f121112b5252f2b4` (epoch 204). Both projects ran through one executor and + one continuity store, with distinct conversation keys and temporary directories. +- B completed with exactly `ACK` using `gemini-3.7-flash-high` and `default`. + A completed with exactly `ACK` using `gemini-3.8-flash-high` and `auto_edit`. + Both continuity records were committed, their directories matched the intended + projects, and their confirmed model/mode matched the requested values. +- Both catalogs were ready with 11 model IDs and `default`, `auto_edit`, `yolo`. + Refreshing A replaced A's entry while B returned the identical cached entry. +- An idle change in A confirmed `gemini-3.6-flash-high` and `default`. After + disposing and recreating the executor, A was restorable and recalled the synthetic + code omitted from the recall prompt. A second disposal/restoration retained the + same external Session and committed configuration. Only equality was logged; + external Session IDs and authentication data were not logged. +- A freshly built development Desktop in the earlier isolated profile verified + connection and Google sign-in. Native automation initially returned stale-element + or `noWindowsAvailable` errors. After the user brought the visible Maka Dev window + to the foreground, UI interaction recovered and the following Desktop run completed. + That temporary automation blocker is resolved; it was not a new HTTP 403. + +Local gates were rerun after integrating `main` at +`1e80e3b885d963b799f7e2e9a70083ceda99943d`. Build, typecheck, lint, +format, renderer architecture, locale hygiene, ASF headers and diff checks passed. +The serial workspace run reported 14,149 tests: 14,111 passed, 38 skipped, zero +failed or cancelled. Following the final epoch-only adjustment, the full build +and 89 protocol tests passed again. The current-base protocol guard passed +202 → 204. Earlier local typecheck failures remain historical, not current failures. +CI for the resulting push must be evaluated separately. + +### Completed same-profile Desktop run + +Tested build: code head `ccd3c195e94bb262df0ef862f121112b5252f2b4`, epoch 204; +record-only head `9580ea7c039e6cb3942750ef9739ce359ece14bb`. This was the ad-hoc +signed `Maka Dev.app` development build on macOS arm64, not a signed distributable. +Profile alias: `desktop-acceptance.xdwVZL`, retained through two normal Cmd-Q exits +and reopenings. Both projects were added through the real native folder picker. + +| Steps | Result | Observed evidence | +| --- | --- | --- | +| 1. Configure Agent | PASS | Official 1.2.1 connection and Google sign-in checks succeeded in this profile. | +| 2–4. Discover, refresh and switch | PASS | B showed four Gemini families and Default/Auto Edit/YOLO. B → A, explicit UI Retry refresh, Gemini 3.8 Flash/Auto Edit selection, and A → B all completed. B candidates remained available without manually refreshing B. Executor cache identity evidence above remains separate. | +| 5. Execute B | PASS | `/private/tmp/pr5826-reaccept-project-b`, `gemini-3.7-flash-high`, confirmed `default`; exact ACK, completed in 7 seconds. Saved continuity was committed with one prompt. | +| 6. Execute A | PASS | `/private/tmp/pr5826-reaccept-project-a`, `gemini-3.8-flash-high`, `auto_edit`; exact ACK, completed in 6 seconds. Both tasks completed in this same profile, with no HTTP 403. | +| 7. Idle change | PASS | A changed through UI to `gemini-3.6-flash-high`/`default`. Task metadata and the Agent-confirmed committed continuity record agreed. | +| 8. Restart and recall | PASS | Normal Cmd-Q/reopen, explicit Restore, retained model/mode. The recall prompt omitted the code; the completed answer was exactly `A-5826-2648` in 15 seconds. | +| 9. Second restart continuity | PASS | A second normal Cmd-Q/reopen and explicit Restore completed. One-way fingerprint equality against the pre-restart record was true; A retained committed phase, two prompts, and confirmed model/mode. No external Session ID was logged. | + +Screenshots, accessibility evidence, redacted continuity projections and local gate +logs are retained in the local run directory `pr5826-acceptance-2026-10-01`. +Cross-project Desktop execution and the full behavior acceptance are closed for this +build. Release signing qualification remains separate. + +## Repeatable acceptance procedure, updated 2026-10-02 + +Use this procedure for PR [#5826](https://github.com/apache/maka/pull/5826). +The historical runs below are evidence, not a claim that every step passed on the +latest code. Record the tested commit, macOS architecture, Node version, Agent +version and hashes, Desktop build kind, and profile alias before starting. + +### Preconditions and automated gate + +1. Check out the PR head in an isolated worktree. Create two empty disposable + projects A and B and a dedicated Desktop profile. Use that **same profile** + throughout the two-project run and its restarts. Build Desktop from the tested + commit; record whether it is a development build, unsigned local app, or signed + distributable. An unsigned app cannot satisfy release-signing acceptance. +2. Use the official Agent and an account eligible to execute prompts. A successful + connection or sign-in check alone does not establish prompt eligibility. Do not + change account or network settings to bypass an eligibility error. +3. Run the repository gates below with dependencies installed. Build before running + compiled tests so stale `dist` output cannot count as evidence. Record each exit + status and retain logs. Run workspace tests serially for the local acceptance + gate; record CI separately, with its exact head SHA and job URL. + +```sh +npm run build +npm run typecheck +npm run lint +npm run format:check +npm run check:renderer-architecture +npm run check:locale-hygiene +npm run check:asf-headers +node scripts/run-workspace-tests-parallel.mjs --concurrency=1 +``` + +Fetch the current PR base before the protocol gate. Pass its fetched ref to +`node scripts/protocol-epoch-check.mjs --base ` and run +`git diff ...HEAD --check`. Reconcile the epoch against that base +before merge; the previous 200 → 201 result is historical evidence. + +The affected suites must exercise fresh-Session model-dependent mode availability, +model-only changes removing a saved mode, idle notification drift, combined-option +rollback, retained Session restoration, neutral probe directory ownership, shared +instance catalog reuse/refresh, and admission held through process cleanup. +The ACP cases live in `packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts`; +Host routing cases live in `packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts`. +Controlled fixtures establish failure/rollback semantics; real Agent runs establish +actual discovery and execution. Keep those results separate. + +### Desktop steps and pass criteria + +| Step | Action | Required result and evidence | +| --- | --- | --- | +| 1. Configure Agent | In external-Agent settings, select the official Agent executable or its extracted directory using the macOS picker, check connection, and verify sign-in. | The executable and sibling helper are validated before saving; both checks succeed. Also verify that incomplete selections report the missing component and preserve the saved path. Capture settings status with account details redacted. | +| 2. Discover B | Add both projects through the project picker, select B, then select Antigravity and open model/mode pickers. | Real model and mode choices appear. Record actual IDs from confirmed task state; labels alone do not establish IDs. | +| 3. Refresh A | Switch B → A and refresh A's catalog with the UI refresh/retry control. Select an available model and mode. | Refresh completes; candidates remain usable and selection is displayed. Capture A's project label and picker. | +| 4. Return to B | Switch A → B without manually refreshing B; inspect its pickers. | B's candidates remain available. Capture B's project label and choices. In a separate production-executor check, assert that A and B share one candidate snapshot and both see the refreshed snapshot. Record that discovery session/new uses only neutral temporary directories. | +| 5. Execute in B | Create a B task with a real model and `default` (if offered). Send: “Do not read or write files or run commands. Remember synthetic code B-5826-7319. Reply only ACK.” | Task reaches completed with ACK, and task metadata records B's directory and confirmed model/mode. An error or HTTP 403 is blocked/failed execution, never a pass. | +| 6. Execute in A | Switch to A. Create a task with another available model and `auto_edit` (if offered). Send the same no-file-access prompt with code A-5826-2648. | Completed with ACK; metadata records A's directory and confirmed selection. Both steps 5 and 6 must pass in this same profile to close cross-project execution. | +| 7. Confirm idle change | In one completed task, change mode and model while idle. | Agent confirmation and inspected task configuration agree with the selected IDs. Do not treat an optimistic picker label as confirmation. | +| 8. Restart and recall | Quit Desktop normally, reopen the same profile, open that task, and invoke restore if shown. Ask: “Do not read or write files or run commands. What synthetic code did I ask you to remember? Reply only the code.” | Completed with the correct prior code, which is absent from the recall prompt. The restored task retains its confirmed model/mode. Capture the recall answer and restored selection. | +| 9. Verify continuity | Compare external Session ID fingerprints before and after a second normal restart/restore. | Fingerprints match and continuity remains committed. Log only the equality result, never the external Session ID or private continuity record. | + +Use actual offered choices if the official Agent catalog changes. Record the chosen +IDs and reason for substitution rather than hard-coding a now-unavailable model. +Save screenshots, task completion status, confirmed selections, and redacted logs +under a run-specific evidence location. Report each step as PASS, FAIL, BLOCKED, +or NOT RUN, with the tested commit. Never promote an earlier profile's success to +a pass for steps 5–6 in a different profile. + +### Blocked-run handoff and completion rule + +On authentication failure, retain the completed discovery/configuration checks and +resume execution after an eligible account is available. On account/location HTTP +403, record the affected project, model, mode, and error category without account +identifiers; leave cross-project execution open. Rerun steps 2–9 in one profile when +eligibility is restored. If code changes, repeat the affected automated gates and +record the new tested commit. Do not substitute a mock Agent for official-Agent +execution acceptance. + +Cross-project acceptance closes only when both project prompts complete in the same +profile. Full behavior acceptance additionally requires confirmed idle changes, +restart recall, same external Session continuity, and passing automated gates on the +tested code. Signed distribution qualification is separate from this feature gate. + +### Earlier verified status before the execution recheck + +- Code head `d8d36d71a6346552f5a9c87c3b35728cc6a87a02`: the + [CI run](https://github.com/apache/maka/actions/runs/36696757301) passed on + 2026-09-30. Verified successful steps include build, typecheck, lint, ASF headers, + locale hygiene, renderer architecture, protocol epoch guard, affected workspace + tests, Runtime Host tests, and Desktop e2e. This supersedes “current-head CI + pending” for that commit; it does not convert historical local typecheck failures + into local passes or establish real official-Agent prompt execution. +- Real Desktop discovery, B → A → B, A refresh, and B availability: PASS in the + previously recorded unsigned app/profile. Production executor probes separately + established per-directory refresh isolation. +- Successful prompt execution in **both** projects of that profile: BLOCKED by + account/location HTTP 403. Earlier single-project prompt/recall/continuity success + remains valid separate evidence. The remaining execution checkbox stays open. + +- An additional 2026-10-01 eligibility probe reused the earlier production-executor + build and official Agent with a disposable directory and a synthetic no-file + prompt. It produced no completion or diagnostic within two minutes; its Agent + process was terminated, the client reported `cancelled`, and the directory was + cleaned up. This is inconclusive, is not a latest-head test, and neither confirms + a new HTTP 403 nor clears the outstanding Desktop execution gate. + +## Official Agent capability gate, 2026-09-29 + +- Platform: macOS arm64. Client: ACP SDK 1.4.0, Node.js 24.19.0. +- No official Antigravity executable or authenticated Agent home was present in this development environment. The official Google macOS arm64 1.1.1 archive was downloaded from the URL in `docs/antigravity-acp-settings.md`. Its server and helper SHA-256 values matched the already recorded distribution hashes there. +- An isolated, disposable ACP client used a temporary toy directory. `initialize` returned protocol version 1, Agent version `agy_acp_server_1.1.1`, and resume/load capabilities. `session/new` returned JSON-RPC `-32000 Authentication required`. The probe sent no prompt, created no Maka task, did not log an external Session ID, and terminated its process group and toy directory. +- The [official ACP registry](https://github.com/agentclientprotocol/registry/blob/main/antigravity-acp/agent.json) listed version 1.2.1. Its Google macOS arm64 archive contained the matching server and helper (SHA-256 `c93c86c0f505fcdf8b13c695bed26d306141ef5446189d591397074d324db34e` and `1b8a2b712ca312c9769e425b800bfbcceec4770f19736404474d1e8e50d65456`). `initialize` returned Agent version `1.2.1`, protocol version 1 and resume/load capabilities; `session/new` again returned `-32000 Authentication required`. Authentication remains required before a real `configOptions` list can be observed. + +At this initial gate, authentication blocked observation of real mode IDs, mode/model interaction, confirmation responses, restoration of mode, and the full Desktop acceptance path. The authenticated follow-up below supersedes those initial capability findings. Different-directory catalogs and the full Desktop path remain unverified with the official Agent. No account or proxy configuration was changed by this work. + +## Authenticated official Agent follow-up, 2026-09-29 + +A later check on macOS arm64 used the official 1.2.1 archive linked by the ACP registry. Its server and helper matched the SHA-256 values above. With the existing authenticated Agent home and a disposable toy workspace: + +- A production `AcpExecutor` catalog probe returned 11 real model IDs and three mode IDs: `default`, `auto_edit`, and `yolo`. The current mode was `default`. +- A fresh Session confirmed `gemini-3.8-flash-high` with `default`, then confirmed an idle change to `auto_edit`. Another idle change confirmed `gemini-3.7-flash-high` with `default`. Inspection returned those exact final values. +- After disposing and recreating the executor, inspection reported `restorable`. Explicit restoration retained the same external Session ID and reconfirmed the saved model and mode. The comparison was made in memory; the ID was not logged. +- Synthetic text prompts with both `gemini-3.8-flash-high` and `gemini-3.7-flash-high` failed with `acp_prompt_failed`. The Agent's output contained HTTP 403 and a location restriction. This check therefore does **not** establish successful prompt execution or post-restart context retention. +- A Desktop development build opened in an isolated worktree profile, but authenticated Desktop selection, refresh, idle change, and restart evidence remains outstanding. The isolated profile has no configured external Agent, and the prompt restriction above still blocks the complete task flow. + +All probes used a temporary project, sent no private project content, and disposed their Agent processes. No account or network settings were changed. At that stage, real discovery, confirmed configuration, and same-Session restoration were verified; full Desktop and successful prompt acceptance were still open. + +## Desktop acceptance, 2026-09-30 + +The Desktop development build ran on macOS arm64 with an isolated user-data directory and two temporary toy folders. The installed Agent was the official 1.2.1 distribution verified above. No private project content was sent to it. + +- The macOS file picker disabled selection of the official Mach-O `agy_acp_server.par` file. Desktop now asks for its extracted directory and resolves the server path from that directory. In the real UI, choosing the directory saved the executable path, `Check connection` succeeded, and Google sign-in verification succeeded. +- In the first toy project, the Antigravity picker showed the four model families backed by the real catalog. The production catalog probe returned 11 model IDs. The mode picker showed `Default`, `Auto Edit`, and `YOLO`. A manual refresh retained the selected model. +- A new Desktop task used `gemini-3.7-flash-high` with `auto_edit`. The first synthetic prompt asked the Agent to remember `RIVER-4821` without reading or writing files. The task completed in nine seconds and answered that it had remembered the code. +- While idle, the same task confirmed a change to `default` and `gemini-3.6-flash-high`. After a normal Desktop quit and restart, the task showed a restore action. Restoration completed with that model and mode. A second prompt omitted the code and asked the Agent to recall it; the completed answer was exactly `RIVER-4821`. +- The private Plugin continuity record showed `phase: committed`, two committed prompts, and the confirmed model and mode. A second normal quit and restart again restored the task. A one-way fingerprint comparison of the record before and after that restart confirmed the same external Session ID; the ID was not logged. + +An initial attempt to add the second toy project for a Desktop directory-isolation check stalled because the macOS project-folder picker disabled its Open button despite the folder being selected. A later isolated app profile successfully added both projects; the second-project result and remaining switching gap are recorded below. + +## P3 follow-up and two-directory Agent check, 2026-09-30 + +The P3 review identified a configuration-order failure: when a new Session begins on a launch model without a mode option, `configureConversation` rejected the requested mode before selecting a model that does expose it. A similar idle Session drift could make restoration of a saved model and mode fail. Configuration now applies the requested model first, validates dependent options against the Agent's response to that model change, and rolls back if the resulting mode is invalid. A fresh Session merges launch defaults with the requested configuration once; a restored Session retains its saved configuration path. + +- Regression tests reproduced both failures before the fix (`acp_config_unavailable: mode`) and pass after it. A third test covers rollback when a mode is invalid after the model switch. The ACP plugin suite passed all 51 tests; the Runtime Host Session catalog coordinator passed all 76 tests. +- The official 1.2.1 Agent was used through the production `AcpExecutor` after Google sign-in. Two separate disposable directories, `/private/tmp/maka-pr5826-project-a` and `/private/tmp/maka-pr5826-project-b`, each returned `ready`, 11 real models, and mode IDs `default`, `auto_edit`, `yolo`. Re-querying each directory returned its cached entry. Explicitly refreshing A returned a new ready entry for A while B still returned its original cached entry. This establishes authenticated per-directory discovery and refresh isolation in the production executor against the real Agent. Both directories returned the same choices, so this does not prove that different workspace configurations produce different catalogs. +- An isolated local `Maka.app` assembled by electron-builder launched and showed the packaged renderer. In that app the extracted official Agent directory was selected with the native picker, connection check succeeded, and Google login verification displayed success. Electron-builder could not finish the macOS arm64 distributable because this machine has no `Developer ID Application` signing identity and the project requires signing. This was an unsigned local app layout, not a completed signed DMG or ZIP. +- After the first isolated app profile stopped responding to desktop automation, a fresh isolated profile successfully registered both toy directories through the native macOS project-folder picker. The official Agent directory was selected in that profile; connection and Google login verification succeeded. With project B selected, the Antigravity picker displayed four real Gemini model families and the `Default`, `Auto Edit`, and `YOLO` modes. Automation initially lost the project-menu target during attempts to switch A ↔ B. A later restart of this same profile completed the switching check described below. +- In the resumed packaged app profile, the project menu switched B → A. A's Antigravity picker showed four Gemini model families. The Desktop `Retry` control started and completed a catalog refresh for A; the candidates remained available. With A selected, `Gemini 3.8 Flash` exposed `Default`, `Auto Edit`, and `YOLO`, and `Auto Edit` could be selected. The menu then switched A → B without manually refreshing B. B still showed the four Gemini families, and `Gemini 3.7 Flash` exposed the same three modes. This verifies the Desktop project-switch and catalog-availability path after an A refresh. Since A and B offer identical candidates, the UI alone cannot prove that B reused its own cache entry; the production `AcpExecutor` object-identity check above establishes A-only refresh at the executor layer. +- A no-file-access prompt was sent in each toy project. The B task recorded `pr5826-project-b`, `gemini-3.7-flash-high`, and `default`; the A task recorded `pr5826-project-a`, `gemini-3.8-flash-high`, and `auto_edit`. Both reached the real Agent and failed with HTTP 403: `Your current account is not eligible for Gemini Code Assist for individuals because it is not currently available in your location.` Thus this profile verifies project and mode routing through task creation, but it does not add successful cross-project prompt execution. The earlier successful prompt and restart-continuation check in the first profile remains separate evidence. +- The Desktop app build passed. The workspace-dependency build stopped at existing `@maka/ui` TypeScript errors involving `@astryxdesign/core` types; the full workspace typecheck is not counted as passing for this follow-up. + +## Implementation and controlled checks + +The generic executor configuration and catalog carry optional opaque mode IDs. ACP maps real +select options returned by the Agent; omitted mode preserves the real task Session default. +The existing Host query and Desktop picker continue to carry models and modes. + +### P2 catalog lifecycle follow-up, 2026-10-02 + +Draft discovery now creates its disposable Session in a neutral temporary directory, rather than +the selected project. One configured ACP executor shares its 60-second catalog across projects. +Explicit refresh invalidates that executor's draft cache; setup/login and provider replacement +invalidate its instance state. Retained task inspection and confirmed configuration are separate. + +The ACP runtime Entry shares Runtime's AdmissionLimiter with a two-probe capacity across adapter +registrations. A permit is held until connection and directory cleanup finish. Refresh replacements +wait for old cleanup, queued invalidations cannot start a process, and the 30-second deadline includes +admission wait. Disposing an executor drains all its superseded probes as well as its latest one. + +The real task still initializes its own Session in its workspace, applies model before dependent +mode, and validates explicit choices before sending a prompt. A value available in the neutral +probe may be unavailable in that task; failure never sends the prompt on a substituted default. +Omitted choices use the real Session defaults. Draft refresh cannot replace a confirmed task choice. + +The configured Agent can perform its own startup work and retain an empty Session. A neutral +directory avoids requesting initialization of the user's project; disabled client filesystem and +terminal capabilities do not sandbox the process. Maka removes its owned temporary directory after +closing the connection and does not attempt generic deletion of Agent-native history. + +The two-directory official-Agent and Desktop evidence above belongs to the previous workspace-scoped +implementation. It remains historical evidence for model/mode execution and continuity, but does +not verify this neutral-probe follow-up. Repeat official-Agent and Desktop acceptance against the +new code before claiming that path verified. For a production-executor check, query A and B, assert +one shared candidate snapshot, refresh once, and verify both now see the refreshed instance snapshot; +record that the Agent session/new requests contain only disposable neutral paths. The old A-only +cache-identity criterion is superseded for ACP draft catalogs. + +Controlled regressions cover neutral directory ownership, cross-project cache sharing, task-specific +candidate rejection without prompting, real Session defaults, admission through cleanup, repeated +refresh handoff, queued disposal and late-result fencing. Existing rollback, same-Session restore +and Composer pending-gate suites remain applicable. + +Local verification for this follow-up: the full workspace build, typecheck, lint and format checks +passed, as did Desktop/UI knip, renderer architecture, locale hygiene and ASF header checks. The ACP +suite passed 84 tests, the Antigravity adapter passed 9, and the affected Runtime, Host, Desktop and +UI suites passed 225, with no skips or failures. The stdio fixture writes native history at +session/new: deliberately passing the selected project reproduces a project file mutation; the +neutral probe prevents it. Removing the crash-cleanup wait also reproduces overlapping replacement +startup. Both regressions pass with the fix restored. No official-Agent or real Desktop run is +claimed for this follow-up. + +### P3 protocol history and program selection follow-up, 2026-10-02 + +The protocol history now records opaque mode IDs and optional catalog refresh once, under the +shipped epoch 204. The redundant merge-description entry and unshipped ACP epoch-203 entry were +removed. The guard passes against fetched main `ab6db58c9` at epoch 202; the wire contract and +shipped epoch are unchanged by this follow-up. + +The macOS native picker allows files and directories. Only a directory selection has the known +server filename appended; the selected executable and its sibling `localharness_external` must be +regular, executable files. Selection and Host check/login share the same filesystem validator. +Invalid selection reports the existing localized executable/helper failure and does not save a +replacement path. Cancellation remains a no-op, and other platforms retain file-only selection. + +Controlled filesystem, settings/IPC, setup/install and protocol suites passed 147 tests without +skips or failures. These exercise actual temporary files, executable permissions, both selection +forms, incomplete distributions and an Electron-prefixed failure reaching the settings banner. +They do not claim a new real macOS dialog or official-Agent run. + +## Historical validation (see latest verified status above) + +- `npm run build`, `npm run typecheck`, `npm run lint`, `npm run format:check`, `npm run check:renderer-architecture`, `npm run check:locale-hygiene`, and `npm run check:asf-headers`: passed. +- For the 2026-09-30 Desktop picker follow-up, the development build completed and targeted Biome lint passed. A fresh Desktop workspace typecheck still reports errors in unchanged browser tools, notifications, overlays, runtime config, and conversation selector files; those errors are not counted as a pass for this follow-up. +- `node scripts/run-workspace-tests-parallel.mjs --concurrency=1` with the bundled Node.js 24.19.0 and Python 3.12.14: all workspaces passed. A separate three-workspace concurrent run was stopped after unrelated timing-sensitive Runtime Host integration cases failed under load; it is not counted as passing validation. +- The protocol epoch advanced to 201 after merging main's storage-usage (199) and Agent Graph (200) protocol updates, for the additive mode and refresh wire fields. The merge-result protocol epoch guard passed against the updated main. +- The initial unauthenticated `session/new` failure and HTTP 403 prompt restriction were resolved for the first Desktop profile above, which verified selection, authentication, catalog refresh, mode and model changes, a completed prompt, and post-restart continuation. A second isolated profile added both toy projects, switched B → A → B, refreshed A, and still rendered B's real catalog without refreshing B. Production `AcpExecutor` probes separately verified per-directory cache and A-only refresh against the official Agent. New prompts in both projects of the second profile failed with the Agent's account/location HTTP 403, so successful cross-project prompt execution remains unverified. diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 4c219699ff9..e3327820ce2 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t | Classification | Count | |---|---:| | windows-backend-gap | 27 | -| portable-candidate | 52 | +| portable-candidate | 53 | | platform-contract | 40 | -Total Windows-excluded declarations: **119** +Total Windows-excluded declarations: **120** ## Inventory @@ -27,6 +27,7 @@ Total Windows-excluded declarations: **119** |---|---|---| | platform-contract | `apps/desktop/scripts/check-renderer-architecture.test.mjs` handles read-only POSIX permissions on the checker directory according to --strict-base | `process.platform === 'win32' \|\| process.getuid?.() === 0` | | portable-candidate | `apps/desktop/scripts/dev-worktree.test.mjs` a symlink to a worktree uses the same data directory | `process.platform === 'win32'` | +| portable-candidate | `apps/desktop/src/main/__tests__/external-agent-executable-selection.test.ts` macOS rejects ${invalid} before returning a path to save | `process.platform === 'win32' && invalid.startsWith('non-executable')` | | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP remove reconciles a live manager after the real store publishes then fails directory sync | `process.platform === 'win32'` | | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP update reconciles the reread authority including an intervening writer without replaying its mutation | `process.platform === 'win32'` | | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP published write explicitly reports out-of-sync when reconciliation ${phase} fails | `process.platform === 'win32'` | diff --git a/packages/acp-executor-plugin/README.md b/packages/acp-executor-plugin/README.md index 357ca147635..2ade577f968 100644 --- a/packages/acp-executor-plugin/README.md +++ b/packages/acp-executor-plugin/README.md @@ -31,3 +31,40 @@ Adapter packages register with `ctx.acp.register(ctx, adapter, config)`. Passing Context explicitly is part of the package ABI: production packages are separate self-contained bundles, and the shared runtime must register the executor against the consumer's scope rather than against its parent Entry. + +## Draft catalogs and task configuration + +ACP draft discovery uses an empty, stable scratch directory owned by Plugin storage. It never passes the selected +project to the Agent's session/new request, sends a prompt, or writes task continuity. Maka does +not inspect project files to construct this catalog. Candidates are shared across projects within +one configured executor instance, +cached for 60 seconds, and invalidated on setup/login, explicit refresh, or provider replacement. +A refresh affects that executor's draft candidates; it never mutates a retained task. +The scratch path is stable across refresh, TTL expiry and Host restarts, scoped to the data root, +Plugin namespace, adapter and composition Entry. A native file lease serializes owners across +runtime instances and processes. Probe processes are drained before directory contents are removed +and the lease is released. A later owner clears residual contents after a crashed Host releases +its OS lease. Direct `AcpExecutor` embeddings without Host storage reuse a temporary path only +within that executor's lifetime; supply `withCatalogDirectory` for durable directory ownership. + +The ACP runtime Entry shares a two-probe FIFO budget across its adapter registrations, using +Runtime's existing AdmissionLimiter. The permit covers initialization, connection disposal and +temporary-directory removal. Replacement probes wait for superseded probes to finish cleanup; +queued probes are cancelled by invalidation or disposal. Individual callers can stop waiting +without owning a shared probe. Pending callers follow invalidations to the current revision even +when setup/login does not start a replacement query. Genuine probe failures do not automatically +retry, and cancellation or disposal stops the caller. The 30-second probe deadline includes time waiting for capacity. +No additional Host protocol or Agent-specific Desktop discovery path is required. + +Draft candidates are advisory. The existing retained Session path initializes in the task's real +workspace and validates the requested model, then its dependent mode, before sending any prompt. +Unavailable explicit values fail instead of silently falling back. Omitted values use the real +Session defaults. Inspection, confirmed selection, rollback and restart continuity remain +independent of the draft cache. Workspace-dependent options can therefore differ from the draft +probe and require the user to choose again. + +The configured Agent remains a trusted local process. Disabled client filesystem/terminal +capabilities and a neutral cwd are not an OS sandbox. Maka closes probe connections and cleans +its scratch directory, but cannot guarantee deletion of Agent-owned empty Session history. +Native history deletion depends on the Agent's private storage format and belongs in a verified +adapter-specific implementation, not the shared ACP runtime. diff --git a/packages/acp-executor-plugin/src/__tests__/acp-catalog.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-catalog.test.ts new file mode 100644 index 00000000000..9ef550b9eb5 --- /dev/null +++ b/packages/acp-executor-plugin/src/__tests__/acp-catalog.test.ts @@ -0,0 +1,311 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { setImmediate } from 'node:timers/promises'; +import { test } from 'node:test'; +import type { ExecutorCatalogEntry } from '@maka/core/executor-catalog'; +import { AdmissionLimiter } from '@maka/runtime/admission-limiter'; +import { AcpCatalog } from '../acp-catalog.js'; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function entry( + id: string, + readiness: ExecutorCatalogEntry['readiness'] = 'ready', +): ExecutorCatalogEntry { + return { + id, + displayName: id, + readiness, + models: [], + supportsAttachments: false, + supportsModelChange: false, + }; +} + +const signal = () => new AbortController().signal; + +test('the shared runtime budget covers cleanup and queues different executor instances', async () => { + const admission = new AdmissionLimiter(2); + const controls = ['a', 'b', 'c'].map((id) => { + const started = deferred(); + const result = deferred(); + const cleanup = deferred(); + const cleaning = deferred(); + const catalog = new AcpCatalog( + admission, + async () => { + started.resolve(); + try { + return await result.promise; + } finally { + cleaning.resolve(); + await cleanup.promise; + } + }, + () => entry(id, 'unavailable'), + ); + return { id, started, result, cleaning, cleanup, catalog }; + }); + const pending = controls.map(({ catalog }) => catalog.get(signal())); + try { + await Promise.all(controls.slice(0, 2).map(({ started }) => started.promise)); + await setImmediate(); + assert.equal(admission.activeCount, 2); + assert.equal(admission.waitingCount, 1); + controls[0]!.result.resolve(entry('a')); + await controls[0]!.cleaning.promise; + assert.equal(admission.activeCount, 2, 'a terminating process still consumes capacity'); + assert.equal(admission.waitingCount, 1); + controls[0]!.cleanup.resolve(); + await controls[2]!.started.promise; + assert.equal(admission.activeCount, 2); + assert.equal(admission.waitingCount, 0); + } finally { + for (const control of controls) { + control.result.resolve(entry(control.id)); + control.cleanup.resolve(); + } + assert.deepEqual( + (await Promise.all(pending)).map((result) => result.readiness), + ['ready', 'ready', 'ready'], + ); + await Promise.all(controls.map(({ catalog }) => catalog.dispose())); + } + assert.equal(admission.activeCount, 0); +}); + +test('repeated refresh drains the old process and redirects every waiter to the latest probe', async () => { + const admission = new AdmissionLimiter(2); + const started = deferred(); + const cleaning = deferred(); + const cleanup = deferred(); + let probes = 0; + const catalog = new AcpCatalog( + admission, + async (startup) => { + probes++; + if (probes > 1) return entry('fresh'); + started.resolve(); + try { + await new Promise((_resolve, reject) => { + startup.addEventListener('abort', () => reject(startup.reason), { once: true }); + }); + return entry('stale'); + } finally { + cleaning.resolve(); + await cleanup.promise; + } + }, + () => entry('unavailable', 'unavailable'), + ); + const old = catalog.get(signal()); + try { + await started.promise; + const refresh = catalog.get(signal(), true); + await cleaning.promise; + const newer = catalog.get(signal(), true); + await setImmediate(); + assert.equal(probes, 1, 'replacement must wait for the old process to close'); + assert.equal(admission.activeCount, 1); + cleanup.resolve(); + const results = await Promise.all([old, refresh, newer]); + assert.equal(probes, 2, 'an invalidated queued refresh must never start a process'); + assert.equal( + results.every((result) => result.id === 'fresh'), + true, + ); + assert.equal(await catalog.get(signal()), results[2]); + } finally { + cleanup.resolve(); + await catalog.dispose(); + } +}); + +test('disposing a queued executor removes its admission without starting a probe', async () => { + const admission = new AdmissionLimiter(1); + const permit = await admission.acquire(signal()); + let probes = 0; + const catalog = new AcpCatalog( + admission, + async () => { + probes++; + return entry('queued'); + }, + () => entry('queued', 'unavailable'), + ); + try { + const pending = catalog.get(signal()); + await setImmediate(); + assert.equal(admission.waitingCount, 1); + await catalog.dispose(); + assert.equal((await pending).readiness, 'unavailable'); + assert.equal(admission.waitingCount, 0); + assert.equal(probes, 0); + } finally { + permit.release(); + await catalog.dispose(); + } +}); + +test('late results from an invalidated probe never re-enter the cache', async () => { + const started = deferred(); + const result = deferred(); + let probes = 0; + const catalog = new AcpCatalog( + new AdmissionLimiter(1), + async () => { + probes++; + if (probes > 1) return entry('new-account'); + started.resolve(); + return await result.promise; + }, + () => entry('agent', 'unavailable'), + ); + try { + const pending = [catalog.get(signal()), catalog.get(signal())]; + await started.promise; + catalog.invalidate(); + result.resolve(entry('old-account')); + const results = await Promise.all(pending); + assert.equal(results[0]!.id, 'new-account'); + assert.equal(results[0], results[1], 'old waiters share one replacement without a new query'); + assert.equal((await catalog.get(signal())).id, 'new-account'); + assert.equal(probes, 2); + } finally { + result.resolve(entry('old-account')); + await catalog.dispose(); + } +}); + +test('genuine unavailable and authentication failures never automatically retry', async () => { + for (const readiness of ['unavailable', 'authentication_required'] as const) { + let probes = 0; + const catalog = new AcpCatalog( + new AdmissionLimiter(1), + async () => { + probes++; + return entry('failed', readiness); + }, + () => entry('failed', 'unavailable'), + ); + try { + assert.equal((await catalog.get(signal())).readiness, readiness); + assert.equal(probes, 1); + } finally { + await catalog.dispose(); + } + } +}); + +test('caller cancellation and disposal do not restart an invalidated probe', async () => { + for (const cancel of [true, false]) { + const started = deferred(); + const result = deferred(); + let probes = 0; + const catalog = new AcpCatalog( + new AdmissionLimiter(1), + async () => { + probes++; + started.resolve(); + return await result.promise; + }, + () => entry('agent', 'unavailable'), + ); + const controller = new AbortController(); + const pending = catalog.get(controller.signal); + const checked = cancel + ? assert.rejects(pending, /stop waiting/) + : pending.then((value) => assert.equal(value.readiness, 'unavailable')); + await started.promise; + catalog.invalidate(); + if (cancel) controller.abort(new Error('stop waiting')); + const disposal = catalog.dispose(); + result.resolve(entry('stale')); + await checked; + await disposal; + assert.equal(probes, 1); + } +}); + +test('repeated invalidations without refresh drain and join the latest revision', async () => { + const started = [deferred(), deferred()]; + const results = [deferred(), deferred()]; + let probes = 0; + const catalog = new AcpCatalog( + new AdmissionLimiter(1), + async () => { + const index = probes++; + if (index === 2) return entry('latest'); + started[index]!.resolve(); + return await results[index]!.promise; + }, + () => entry('agent', 'unavailable'), + ); + const waiting = [catalog.get(signal()), catalog.get(signal())]; + try { + await started[0]!.promise; + catalog.invalidate(); + results[0]!.resolve(entry('stale-1')); + await started[1]!.promise; + catalog.invalidate(); + results[1]!.resolve(entry('stale-2')); + assert.deepEqual( + (await Promise.all(waiting)).map((value) => value.id), + ['latest', 'latest'], + ); + assert.equal(probes, 3); + } finally { + for (const result of results) result.resolve(entry('cleanup')); + await catalog.dispose(); + } +}); + +test('ready catalog cache expires without a timer or caller refresh', async () => { + let calls = 0; + let now = 100000; + const original = Date.now; + const catalog = new AcpCatalog( + new AdmissionLimiter(1), + async () => { + calls++; + return entry('ready'); + }, + () => entry('unavailable', 'unavailable'), + ); + try { + Date.now = () => now; + await catalog.get(signal()); + await catalog.get(signal()); + assert.equal(calls, 1); + now += 61000; + await catalog.get(signal()); + assert.equal(calls, 2); + } finally { + Date.now = original; + await catalog.dispose(); + } +}); diff --git a/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts index 7b50b0f4d95..538c91f3ccd 100644 --- a/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts +++ b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts @@ -18,11 +18,12 @@ */ import assert from 'node:assert/strict'; -import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { chmod, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; import { methods, type ClientApp, type ClientConnection } from '@agentclientprotocol/sdk'; +import { AdmissionLimiter } from '@maka/runtime/admission-limiter'; import type { PluginExecutorContext } from '@maka/runtime/plugin-executor-service'; import { AcpExecutor, @@ -972,6 +973,14 @@ test('discovery shares a disposable probe, does not mark a task, and first promp ); assert.equal(protocol.connections, 1); assert.equal(protocol.disposals, 1); + assert.equal(protocol.sessionCwds.length, 1); + const probeCwd = protocol.sessionCwds[0]!; + assert.notEqual(probeCwd, await realpath(fixture.root)); + assert.notEqual(probeCwd, process.cwd()); + assert.equal(protocol.clientCapabilities[0]?.fs?.readTextFile, false); + assert.equal(protocol.clientCapabilities[0]?.fs?.writeTextFile, false); + assert.equal(protocol.clientCapabilities[0]?.terminal, false); + await assert.rejects(stat(probeCwd), { code: 'ENOENT' }); assert.equal(marks, 0); assert.equal(protocol.prompts, 0); assert.equal( @@ -984,6 +993,7 @@ test('discovery shares a disposable probe, does not mark a task, and first promp 'completed', ); assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.sessionCwds[1], process.cwd()); assert.equal(marks, 1); assert.equal(protocol.connections, 2); const before = protocol.connections; @@ -1022,6 +1032,862 @@ test('discovery shares a disposable probe, does not mark a task, and first promp } }); +test('a catalog caller can cancel without owning or leaking the shared probe', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.holdInitialize = true; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + const abort = new AbortController(); + try { + const discovery = executor.discover({ cwd: fixture.root, signal: abort.signal }); + for (let attempt = 0; attempt < 20 && protocol.connections === 0; attempt++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(protocol.connections, 1); + abort.abort(new Error('Catalog caller cancelled')); + await assert.rejects(discovery, /caller cancelled/u); + assert.equal(protocol.disposals, 0, 'caller cancellation must not own the shared probe'); + } finally { + await executor.dispose(); + assert.equal(protocol.disposals, 1, 'provider disposal must abort and drain the shared probe'); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('refresh redirects callers awaiting the superseded catalog probe', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.holdInitialize = true; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + const waiting = executor.discover({ + cwd: fixture.root, + signal: new AbortController().signal, + }); + for (let attempt = 0; attempt < 20 && protocol.connections === 0; attempt++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal(protocol.connections, 1); + protocol.holdInitialize = false; + const refreshed = executor.discover({ + cwd: fixture.root, + signal: new AbortController().signal, + refresh: true, + }); + const [waitingResult, refreshedResult] = await Promise.all([waiting, refreshed]); + assert.equal(waitingResult.readiness, 'ready'); + assert.deepEqual(waitingResult, refreshedResult); + assert.equal(protocol.connections, 2); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a failed probe keeps admission until its already-started process cleanup finishes', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.holdInitialize = true; + const admission = new AdmissionLimiter(1); + let crash!: (error: Error) => void; + let releaseCleanup!: () => void; + const cleanup = new Promise((resolve) => { + releaseCleanup = resolve; + }); + let started!: () => void; + const connectionStarted = new Promise((resolve) => { + started = resolve; + }); + let cleaning!: () => void; + const cleanupStarted = new Promise((resolve) => { + cleaning = resolve; + }); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + catalogAdmission: admission, + createConnection: (input) => { + const owner = protocol.factory(input); + if (protocol.connections > 1) return owner; + started(); + return { + ...owner, + failed: new Promise((_resolve, reject) => { + crash = reject; + }), + dispose: async () => { + cleaning(); + await cleanup; + await owner.dispose(); + }, + }; + }, + }, + ); + try { + const old = executor.discover({ cwd: fixture.root, signal: new AbortController().signal }); + await connectionStarted; + crash(new Error('Probe process crashed')); + await cleanupStarted; + protocol.holdInitialize = false; + const replacement = executor.discover({ + cwd: fixture.root, + signal: new AbortController().signal, + refresh: true, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + assert.equal(protocol.connections, 1, 'a replacement cannot overlap crash cleanup'); + assert.equal(admission.activeCount, 1); + releaseCleanup(); + assert.equal((await replacement).readiness, 'ready'); + assert.equal((await old).readiness, 'ready'); + assert.equal(protocol.disposals, 2); + assert.equal(admission.activeCount, 0); + } finally { + releaseCleanup(); + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('ACP modes remain distinct from models across discovery, prompt, idle change and restoration', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.supportsRestore = true; + const storage = durableState(); + const make = () => + new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + state: storage.state, + }, + ); + const first = make(); + try { + const catalog = await first.discover({ + cwd: fixture.root, + signal: new AbortController().signal, + }); + assert.deepEqual( + catalog.modes?.map((mode) => mode.id), + ['ask', 'auto'], + ); + assert.equal(catalog.currentMode, 'ask'); + assert.equal(catalog.supportsModeChange, true); + const requestWithMode = { ...request('first'), configuration: { model: 'fast', mode: 'auto' } }; + assert.equal((await first.execute(requestWithMode, executorContext([]))).status, 'completed'); + assert.deepEqual(protocol.promptModels, ['fast']); + assert.deepEqual(protocol.promptModes, ['auto']); + assert.equal(storage.record().confirmedMode, 'auto'); + await first.acknowledgeExecution('session-a', 'turn-first'); + await first.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { mode: 'ask' } }, + new AbortController().signal, + ); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.selectedMode, 'ask'); + assert.equal(storage.record().confirmedMode, 'ask'); + await assert.rejects( + first.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { mode: 'invented' } }, + new AbortController().signal, + ), + ); + assert.equal(protocol.selectedMode, 'ask'); + await first.dispose(); + protocol.selectedModel = 'default'; + protocol.selectedMode = 'auto'; + const restored = make(); + try { + const saved = await restored.inspectConversation({ + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'ask' }, + }); + assert.equal(saved.readiness, 'restorable'); + assert.deepEqual( + saved.models.map((model) => model.id), + ['fast'], + ); + assert.deepEqual( + saved.modes?.map((mode) => mode.id), + ['ask'], + ); + await restored.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'ask' }, + }, + new AbortController().signal, + ); + assert.equal( + (await restored.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() })) + .currentMode, + 'ask', + ); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.selectedMode, 'ask'); + assert.equal( + protocol.sessions, + 2, + 'one catalog probe and one retained Session; restore does not create another', + ); + } finally { + await restored.dispose(); + } + } finally { + await first.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('merged configuration preserves the model when a mode change opens a fresh Session', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + assert.deepEqual( + await executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'auto' }, + }, + new AbortController().signal, + ), + { model: 'fast', mode: 'auto' }, + ); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.selectedMode, 'auto'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a fresh Session selects a model before validating a mode unavailable on the launch model', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.dropModeForModel = 'default'; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable, model: 'default' }, + { createConnection: protocol.factory }, + ); + try { + assert.deepEqual( + await executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'auto' }, + }, + new AbortController().signal, + ), + { model: 'fast', mode: 'auto' }, + ); + assert.equal(protocol.sessions, 1); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.selectedMode, 'auto'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('an idle model drift does not block restoring the saved mode with a model change', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.dropModeForModel = 'default'; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + await executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'auto' }, + }, + new AbortController().signal, + ); + protocol.notifyConfiguration('default'); + assert.deepEqual( + await executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'auto' }, + }, + new AbortController().signal, + ), + { model: 'fast', mode: 'auto' }, + ); + assert.equal(protocol.sessions, 1); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(protocol.selectedMode, 'auto'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +for (const mode of ['ask', 'invented']) + test(`a rejected ${mode} mode restores the model before its dependent mode`, async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.modeValuesByModel = { default: ['auto', 'ask'], fast: ['auto'] }; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const input = { conversationKey: 'session-a', cwd: process.cwd() }; + try { + await executor.configureConversation( + { ...input, configuration: { model: 'default', mode: 'ask' } }, + new AbortController().signal, + ); + const original = structuredClone(storage.record()); + await assert.rejects( + executor.configureConversation( + { ...input, configuration: { model: 'fast', mode } }, + new AbortController().signal, + ), + { code: 'acp_config_invalid' }, + ); + assert.equal(protocol.selectedModel, 'default'); + assert.equal(protocol.selectedMode, 'ask'); + assert.deepEqual(storage.record(), original); + const catalog = await executor.inspectConversation(input); + assert.equal(catalog.readiness, 'ready'); + assert.equal(catalog.currentModel, 'default'); + assert.equal(catalog.currentMode, 'ask'); + assert.equal(protocol.disposals, 0); + assert.equal( + ( + await executor.execute( + { ...request('after-rollback'), configuration: { model: 'default', mode: 'ask' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + await executor.acknowledgeExecution('session-a', 'turn-after-rollback'); + assert.deepEqual(protocol.promptModels, ['default']); + assert.deepEqual(protocol.promptModes, ['ask']); + assert.equal(protocol.sessions, 1); + assert.equal(storage.record().sessionId, original.sessionId); + assert.equal(storage.record().phase, 'committed'); + assert.equal(storage.record().confirmedModel, 'default'); + assert.equal(storage.record().confirmedMode, 'ask'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } + }); + +test('confirmed configuration clears a mode removed by a model change', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.supportsRestore = true; + const storage = durableState(); + const make = () => + new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const first = make(); + try { + assert.equal( + ( + await first.execute( + { ...request('first'), configuration: { model: 'default', mode: 'ask' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + await first.acknowledgeExecution('session-a', 'turn-first'); + protocol.dropModeForModel = 'fast'; + assert.deepEqual( + await first.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'fast' } }, + new AbortController().signal, + ), + { model: 'fast' }, + ); + assert.equal(storage.record().confirmedModel, 'fast'); + assert.equal(storage.record().confirmedMode, undefined); + await first.dispose(); + + const restored = make(); + try { + assert.deepEqual( + await restored.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'fast' } }, + new AbortController().signal, + ), + { model: 'fast' }, + ); + assert.equal( + ( + await restored.execute( + { ...request('second'), configuration: { model: 'fast' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + } finally { + await restored.dispose(); + } + } finally { + await first.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a fresh Session confirms a model-only change that removes the default mode', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.dropModeForModel = 'fast'; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + try { + assert.deepEqual( + await executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast' }, + }, + new AbortController().signal, + ), + { model: 'fast' }, + ); + assert.equal(protocol.selectedModel, 'fast'); + assert.equal(storage.record().confirmedModel, 'fast'); + assert.equal(storage.record().confirmedMode, undefined); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +for (const drift of ['removed', 'replaced'] as const) + test(`a ${drift} saved mode rejects followups but permits explicit model-only recovery`, async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.supportsRestore = true; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const configuration = { model: 'fast', mode: 'ask' }; + try { + assert.equal( + (await executor.execute({ ...request('first'), configuration }, executorContext([]))) + .status, + 'completed', + ); + await executor.acknowledgeExecution('session-a', 'turn-first'); + const confirmed = structuredClone(storage.record()); + if (drift === 'removed') protocol.dropModeForModel = 'fast'; + else protocol.modeValuesByModel = { fast: ['auto'] }; + protocol.notifyConfiguration('default'); + const result = await executor.execute( + { ...request('second'), configuration }, + executorContext([]), + ); + assert.equal(result.status, 'failed'); + if (result.status === 'failed') + assert.equal( + result.code, + drift === 'removed' ? 'acp_config_unavailable' : 'acp_config_invalid', + ); + assert.equal(protocol.prompts, 1, 'an invalid saved mode cannot admit another prompt'); + assert.deepEqual( + storage.record(), + confirmed, + 'failed validation preserves the confirmed selection', + ); + const input = { conversationKey: 'session-a', cwd: process.cwd() }; + assert.equal((await executor.inspectConversation(input)).readiness, 'restore_failed'); + await assert.rejects( + executor.configureConversation({ ...input, configuration }, new AbortController().signal), + ); + assert.deepEqual( + storage.record(), + confirmed, + 'an explicit invalid mode cannot be waived by restoration', + ); + const recovered = await executor.configureConversation( + { ...input, configuration: { model: 'fast' } }, + new AbortController().signal, + ); + assert.deepEqual( + recovered, + drift === 'removed' ? { model: 'fast' } : { model: 'fast', mode: 'auto' }, + ); + assert.equal((await executor.inspectConversation(input)).readiness, 'ready'); + assert.equal(storage.record().confirmedMode, recovered.mode); + assert.equal( + ( + await executor.execute( + { ...request('recovered'), configuration: recovered }, + executorContext([]), + ) + ).status, + 'completed', + ); + await executor.acknowledgeExecution('session-a', 'turn-recovered'); + assert.equal(protocol.prompts, 2); + assert.equal(protocol.sessions, 1, 'recovery preserves the original external Session'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } + }); + +test('a stale draft mode can recover the first prompt without creating another external Session', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.supportsRestore = true; + protocol.dropModeForModel = 'fast'; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + state: storage.state, + }, + ); + const input = { conversationKey: 'session-a', cwd: process.cwd() }; + try { + const result = await executor.execute( + { ...request('first'), configuration: { model: 'fast', mode: 'ask' } }, + executorContext([]), + ); + assert.equal(result.status, 'failed'); + assert.equal(protocol.prompts, 0); + const inspected = await executor.inspectConversation(input); + assert.equal(inspected.readiness, 'restore_failed'); + assert.equal(inspected.currentModel, 'fast'); + assert.equal(inspected.supportsModeChange, false); + const configuration = await executor.configureConversation( + { ...input, configuration: { model: 'fast' } }, + new AbortController().signal, + ); + assert.deepEqual(configuration, { model: 'fast' }); + assert.equal( + (await executor.execute({ ...request('retry'), configuration }, executorContext([]))).status, + 'completed', + ); + assert.equal(protocol.sessions, 1); + assert.equal(protocol.prompts, 1); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('an explicit mode removed by a model change rolls back without clearing the saved selection', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const input = { conversationKey: 'session-a', cwd: process.cwd() }; + try { + await executor.configureConversation( + { ...input, configuration: { model: 'default', mode: 'ask' } }, + new AbortController().signal, + ); + const confirmed = structuredClone(storage.record()); + protocol.dropModeForModel = 'fast'; + await assert.rejects( + executor.configureConversation( + { ...input, configuration: { model: 'fast', mode: 'ask' } }, + new AbortController().signal, + ), + { code: 'acp_config_unavailable' }, + ); + assert.equal(protocol.selectedModel, 'default'); + assert.equal(protocol.selectedMode, 'ask'); + assert.equal(protocol.prompts, 0); + assert.equal(protocol.disposals, 0, 'confirmed rollback keeps the retained Session'); + assert.deepEqual(storage.record(), confirmed); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('a failed second configuration option restores both model and mode', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + assert.equal( + (await executor.execute(request('first'), executorContext([]))).status, + 'completed', + ); + protocol.configurationFailure = 'mode_once'; + await assert.rejects( + executor.configureConversation( + { + conversationKey: 'session-a', + cwd: process.cwd(), + configuration: { model: 'fast', mode: 'auto' }, + }, + new AbortController().signal, + ), + ); + assert.equal(protocol.selectedModel, 'default'); + assert.equal(protocol.selectedMode, 'ask'); + const state = await executor.inspectConversation({ + conversationKey: 'session-a', + cwd: process.cwd(), + }); + assert.equal(state.readiness, 'ready'); + assert.equal(state.currentModel, 'default'); + assert.equal(state.currentMode, 'ask'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('late configuration notifications are reasserted before the next change', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + }, + ); + try { + assert.equal( + (await executor.execute(request('first'), executorContext([]))).status, + 'completed', + ); + await executor.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'fast' } }, + new AbortController().signal, + ); + protocol.notifyConfiguration('default'); + assert.equal( + (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() })) + .currentModel, + 'default', + ); + await executor.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'fast' } }, + new AbortController().signal, + ); + assert.equal(protocol.selectedModel, 'fast'); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('draft catalogs share one neutral probe across projects and refresh the executor instance', async () => { + const fixture = await executableFixture(); + const other = await mkdtemp(join(tmpdir(), 'maka-acp-catalog-other-')); + const protocol = fakeProtocol(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + const signal = new AbortController().signal; + try { + const [a, b] = await Promise.all([ + executor.discover({ cwd: fixture.root, signal }), + executor.discover({ cwd: other, signal }), + ]); + assert.equal(a, b); + assert.equal(protocol.connections, 1); + const cached = await executor.discover({ cwd: join(other, 'not-created'), signal }); + assert.equal(cached, a, 'draft discovery neither reads nor validates the project path'); + const refreshed = await executor.discover({ cwd: fixture.root, signal, refresh: true }); + assert.notEqual(refreshed, a); + assert.equal(await executor.discover({ cwd: other, signal }), refreshed); + assert.equal(protocol.connections, 2); + executor.invalidateCatalog(); + assert.equal((await executor.discover({ cwd: other, signal })).readiness, 'ready'); + assert.equal(protocol.connections, 3); + assert.equal( + new Set(protocol.sessionCwds).size, + 1, + 'refresh and invalidation reuse the neutral cwd', + ); + for (const cwd of protocol.sessionCwds) { + assert.notEqual(cwd, await realpath(fixture.root)); + assert.notEqual(cwd, await realpath(other)); + await assert.rejects(stat(cwd), { code: 'ENOENT' }); + } + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + await rm(other, { recursive: true, force: true }); + } +}); + +for (const selection of ['model', 'mode'] as const) { + test( + 'a project Session rejects a ' + + selection + + ' offered only by the neutral draft probe without prompting', + async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + const project = await realpath(fixture.root); + protocol.onSessionNew = (cwd) => { + if (cwd === project) { + if (selection === 'model') protocol.modelValues = ['default']; + else protocol.hasMode = false; + } + }; + const storage = durableState(); + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + createConnection: protocol.factory, + state: storage.state, + }, + ); + const configuration = selection === 'model' ? { model: 'fast' } : { mode: 'auto' }; + try { + const catalog = await executor.discover({ + cwd: project, + signal: new AbortController().signal, + }); + assert.equal( + catalog.models.some((model) => model.id === 'fast'), + true, + ); + assert.equal( + catalog.modes?.some((mode) => mode.id === 'auto'), + true, + ); + const result = await executor.execute( + { ...request('workspace'), cwd: project, configuration }, + executorContext([]), + ); + assert.equal(result.status, 'failed'); + if (result.status === 'failed') + assert.equal( + result.code, + selection === 'model' ? 'acp_config_invalid' : 'acp_config_unavailable', + ); + assert.equal( + protocol.prompts, + 0, + 'an invalid choice must never send the draft on a default', + ); + assert.equal(protocol.sessionCwds[1], project); + assert.equal(storage.record().phase, 'established'); + assert.equal(storage.record().pendingTurnId, undefined); + assert.deepEqual( + configuration, + selection === 'model' ? { model: 'fast' } : { mode: 'auto' }, + ); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } + }, + ); +} + +test('an unspecified task configuration uses project defaults independently of the probe', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + const project = await realpath(fixture.root); + protocol.onSessionNew = (cwd) => { + if (cwd === project) { + protocol.selectedModel = 'fast'; + protocol.selectedMode = 'auto'; + } + }; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + const catalog = await executor.discover({ cwd: project, signal: new AbortController().signal }); + assert.equal(catalog.currentModel, 'default'); + assert.equal(catalog.currentMode, 'ask'); + assert.equal( + (await executor.execute({ ...request('defaults'), cwd: project }, executorContext([]))) + .status, + 'completed', + ); + assert.deepEqual(protocol.promptModels, ['fast']); + assert.deepEqual(protocol.promptModes, ['auto']); + assert.equal( + await executor.discover({ cwd: project, signal: new AbortController().signal }), + catalog, + 'live configuration cannot overwrite draft candidates', + ); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + for (const failure of ['response_lost', 'timeout'] as const) { test(`an applied model change with ${failure} prevents prompts using stale configuration`, async () => { const fixture = await executableFixture(); @@ -1160,9 +2026,23 @@ function fakeProtocol(): { prompts: number; disposals: number; selectedModel?: string; - configurationFailure?: 'response_lost' | 'unconfirmed' | 'timeout' | 'once'; + selectedMode?: string; + hasMode: boolean; + dropModeForModel?: string; + modeValuesByModel?: Readonly>; + notifyDuringPromptModel?: string; + holdInitialize: boolean; + sessionCwds: string[]; + clientCapabilities: Array<{ + fs?: { readTextFile?: boolean; writeTextFile?: boolean }; + terminal?: boolean; + }>; + modelValues: string[]; + onSessionNew?: (cwd: string) => void; + configurationFailure?: 'response_lost' | 'unconfirmed' | 'timeout' | 'once' | 'mode_once'; sessionCreationFailure?: 'once'; promptModels: string[]; + promptModes: Array; supportsRestore: boolean; restoreFailure?: boolean; resumes: number; @@ -1179,14 +2059,29 @@ function fakeProtocol(): { prompts: 0, disposals: 0, selectedModel: undefined as string | undefined, + selectedMode: 'ask', + hasMode: false, + dropModeForModel: undefined as string | undefined, + modeValuesByModel: undefined as Readonly> | undefined, + notifyDuringPromptModel: undefined as string | undefined, + holdInitialize: false, + sessionCwds: [] as string[], + clientCapabilities: [] as Array<{ + fs?: { readTextFile?: boolean; writeTextFile?: boolean }; + terminal?: boolean; + }>, + modelValues: ['default', 'fast'], + onSessionNew: undefined as ((cwd: string) => void) | undefined, configurationFailure: undefined as | 'response_lost' | 'unconfirmed' | 'timeout' | 'once' + | 'mode_once' | undefined, sessionCreationFailure: undefined as 'once' | undefined, promptModels: [] as string[], + promptModes: [] as Array, supportsRestore: false, restoreFailure: false, resumes: 0, @@ -1198,6 +2093,37 @@ function fakeProtocol(): { notifyConfiguration: (_model: string): void => {}, factory: undefined as unknown as AcpConnectionFactory, }; + const configOptions = () => [ + { + type: 'select', + id: 'model', + name: 'Model', + currentValue: fixture.selectedModel ?? 'default', + options: fixture.modelValues.map((value) => ({ + value, + name: value === 'default' ? 'Default' : 'Fast', + })), + }, + ...(fixture.hasMode && + (fixture.dropModeForModel === undefined || + (fixture.selectedModel ?? 'default') !== fixture.dropModeForModel) + ? [ + { + type: 'select', + id: 'mode', + category: 'mode', + name: 'Mode', + currentValue: fixture.selectedMode, + options: ( + fixture.modeValuesByModel?.[fixture.selectedModel ?? 'default'] ?? ['ask', 'auto'] + ).map((value) => ({ + value, + name: value === 'ask' ? 'Ask before edits' : 'Autonomous', + })), + }, + ] + : []), + ]; fixture.factory = (input) => { fixture.connections += 1; const notifications = new Map unknown>(); @@ -1235,23 +2161,13 @@ function fakeProtocol(): { params: { sessionId: 'acp-session', path, content } as never, }); fixture.notifyConfiguration = (model) => { + fixture.selectedModel = model; notifications.get(methods.client.session.update)?.({ params: { sessionId: 'acp-session', update: { sessionUpdate: 'config_option_update', - configOptions: [ - { - type: 'select', - id: 'model', - name: 'Model', - currentValue: model, - options: [ - { value: 'default', name: 'Default' }, - { value: 'fast', name: 'Fast' }, - ], - }, - ], + configOptions: configOptions(), }, } as never, }); @@ -1263,13 +2179,24 @@ function fakeProtocol(): { params: Record, options?: { cancellationSignal?: AbortSignal }, ) => { - if (method === methods.agent.initialize) + if (method === methods.agent.initialize) { + fixture.clientCapabilities.push( + params.clientCapabilities as (typeof fixture.clientCapabilities)[number], + ); + if (fixture.holdInitialize) { + const signal = options!.cancellationSignal!; + signal.throwIfAborted(); + await new Promise((_, reject) => + signal.addEventListener('abort', () => reject(signal.reason), { once: true }), + ); + } return { protocolVersion: 1, agentCapabilities: fixture.supportsRestore ? { loadSession: true, sessionCapabilities: { resume: {} } } : {}, }; + } if (method === methods.agent.session.resume || method === methods.agent.session.load) { assert.equal(params.sessionId, 'acp-session'); assert.equal(params.cwd, process.cwd()); @@ -1286,46 +2213,33 @@ function fakeProtocol(): { params: { sessionId: 'acp-session', update } as never, }); } - return { - configOptions: [ - { - type: 'select', - id: 'model', - name: 'Model', - currentValue: fixture.selectedModel ?? 'default', - options: [ - { value: 'default', name: 'Default' }, - { value: 'fast', name: 'Fast' }, - ], - }, - ], - }; + return { configOptions: configOptions() }; } if (method === methods.agent.session.new) { fixture.sessions += 1; + fixture.sessionCwds.push(String(params.cwd)); + fixture.onSessionNew?.(String(params.cwd)); if (fixture.sessionCreationFailure === 'once') { fixture.sessionCreationFailure = undefined; throw new Error('Session creation response was lost'); } - return { - sessionId: 'acp-session', - configOptions: [ - { - type: 'select', - id: 'model', - name: 'Model', - currentValue: 'default', - options: [ - { value: 'default', name: 'Default' }, - { value: 'fast', name: 'Fast' }, - ], - }, - ], - }; + return { sessionId: 'acp-session', configOptions: configOptions() }; } if (method === methods.agent.session.setConfigOption) { - fixture.selectedModel = String(params.value); - if (fixture.configurationFailure === 'once') { + if (params.configId === 'mode') { + const mode = configOptions().find((option) => option.id === 'mode'); + if (!mode?.options.some((option) => option.value === params.value)) + throw new Error('Mode unavailable for the selected model'); + fixture.selectedMode = String(params.value); + } else { + fixture.selectedModel = String(params.value); + const modeValues = fixture.modeValuesByModel?.[fixture.selectedModel]; + if (modeValues) fixture.selectedMode = modeValues[0]!; + } + if ( + fixture.configurationFailure === 'once' || + (fixture.configurationFailure === 'mode_once' && params.configId === 'mode') + ) { fixture.configurationFailure = undefined; throw new Error('Transient rejection after mutation'); } @@ -1339,24 +2253,22 @@ function fakeProtocol(): { ); } return { - configOptions: [ - { - type: 'select', - id: 'model', - name: 'Model', - currentValue: - fixture.configurationFailure === 'unconfirmed' ? 'default' : params.value, - options: [ - { value: 'default', name: 'Default' }, - { value: 'fast', name: 'Fast' }, - ], - }, - ], + configOptions: + fixture.configurationFailure === 'unconfirmed' + ? configOptions().map((option) => + option.id === params.configId + ? { ...option, currentValue: option.id === 'mode' ? 'ask' : 'default' } + : option, + ) + : configOptions(), }; } if (method === methods.agent.session.prompt) { fixture.prompts += 1; fixture.promptModels.push(fixture.selectedModel ?? 'default'); + fixture.promptModes.push(fixture.selectedMode); + if (fixture.notifyDuringPromptModel) + fixture.notifyConfiguration(fixture.notifyDuringPromptModel); const text = (params.prompt as Array<{ text: string }>)[0]!.text; await requests.get(methods.client.session.requestPermission)?.({ params: { @@ -1486,26 +2398,217 @@ for (const failure of ['once', 'unconfirmed'] as const) } }); -test('idle Agent configuration notifications update the inspected model without another prompt', async () => { +test('idle Agent configuration notifications cannot diverge from the Session configuration', async () => { const fixture = await executableFixture(); const protocol = fakeProtocol(); + const storage = durableState(); const executor = new AcpExecutor( adapter, { executable: fixture.executable }, - { createConnection: protocol.factory }, + { createConnection: protocol.factory, state: storage.state }, ); try { await executor.execute( { ...request('first'), configuration: { model: 'default' } }, executorContext([]), ); + await executor.acknowledgeExecution('session-a', 'turn-first'); + protocol.notifyConfiguration('fast'); protocol.notifyConfiguration('fast'); assert.equal( (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() })) .currentModel, 'fast', ); - assert.equal(protocol.prompts, 1); + assert.equal(storage.record().confirmedModel, 'default'); + assert.equal( + ( + await executor.execute( + { ...request('second'), configuration: { model: 'default' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + assert.deepEqual(protocol.promptModels, ['default', 'default']); + } finally { + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('an Agent configuration update during a prompt cannot replace the saved selection', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.supportsRestore = true; + const storage = durableState(); + const make = () => + new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const first = make(); + try { + protocol.notifyDuringPromptModel = 'fast'; + assert.equal( + ( + await first.execute( + { ...request('first'), configuration: { model: 'default' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + assert.equal(storage.record().confirmedModel, 'default'); + await first.acknowledgeExecution('session-a', 'turn-first'); + assert.equal(storage.record().confirmedModel, 'default'); + await first.dispose(); + + protocol.notifyDuringPromptModel = undefined; + const restored = make(); + try { + assert.equal( + ( + await restored.execute( + { ...request('second'), configuration: { model: 'default' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + assert.deepEqual(protocol.promptModels, ['default', 'default']); + assert.equal(storage.record().confirmedModel, 'default'); + } finally { + await restored.dispose(); + } + } finally { + await first.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('registered-style directory ownership covers Agent startup and cleanup', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + const started = deferred(); + const cleanup = deferred(); + let owned = false; + let released = false; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { + withCatalogDirectory: async (_signal, use) => { + owned = true; + try { + return await use(fixture.root); + } finally { + released = true; + owned = false; + } + }, + createConnection: (input) => { + assert.equal(owned, true); + const connection = protocol.factory(input); + return { + ...connection, + dispose: async () => { + started.resolve(); + await cleanup.promise; + assert.equal(owned, true, 'the directory is leased until process cleanup finishes'); + await connection.dispose(); + }, + }; + }, + }, + ); + const waiting = executor.discover({ cwd: fixture.root, signal: new AbortController().signal }); + try { + await started.promise; + assert.equal(released, false); + cleanup.resolve(); + assert.equal((await waiting).readiness, 'ready'); + assert.equal(released, true); + } finally { + cleanup.resolve(); + await executor.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +test('restoration reasserts the saved mode even when the request only selects a model', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + protocol.hasMode = true; + protocol.supportsRestore = true; + const storage = durableState(); + const make = () => + new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory, state: storage.state }, + ); + const first = make(); + let restored: AcpExecutor | undefined; + try { + assert.equal( + ( + await first.execute( + { ...request('first'), configuration: { model: 'fast', mode: 'auto' } }, + executorContext([]), + ) + ).status, + 'completed', + ); + await first.acknowledgeExecution('session-a', 'turn-first'); + await first.dispose(); + protocol.selectedMode = 'ask'; + restored = make(); + await restored.configureConversation( + { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'fast' } }, + new AbortController().signal, + ); + assert.equal( + protocol.selectedMode, + 'auto', + 'resume must preserve the mode already acknowledged by the task', + ); + } finally { + await restored?.dispose(); + await first.dispose(); + await rm(fixture.root, { recursive: true, force: true }); + } +}); + +test('draft discovery does not apply project-specific startup configuration', async () => { + const fixture = await executableFixture(); + const protocol = fakeProtocol(); + const project = await realpath(fixture.root); + protocol.onSessionNew = (cwd) => { + if (cwd === project) protocol.selectedModel = 'fast'; + }; + const executor = new AcpExecutor( + adapter, + { executable: fixture.executable }, + { createConnection: protocol.factory }, + ); + try { + const result = await executor.discover({ cwd: project, signal: new AbortController().signal }); + assert.equal( + result.currentModel, + 'default', + 'project-specific startup must not run during discovery', + ); + assert.equal(protocol.sessionCwds.includes(project), false); } finally { await executor.dispose(); await rm(fixture.root, { recursive: true, force: true }); diff --git a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts index aa3e2902ea1..e1c2c963295 100644 --- a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts +++ b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { mkdtemp, writeFile, readFile, rm, symlink } from 'node:fs/promises'; +import { mkdtemp, writeFile, readFile, readdir, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { AcpExecutor, type AcpAgentAdapter, type AcpContinuityRecord } from '../index.js'; @@ -168,6 +168,27 @@ test('file read rejects invalid ranges and honors a zero limit', async () => { } }); +test('real stdio discovery keeps Agent startup history out of the selected project', async () => { + const f = await fixture(); + try { + const catalog = await f.executor.discover({ + cwd: f.root, + signal: new AbortController().signal, + }); + assert.equal(catalog.readiness, 'ready'); + // This Agent writes its history during session/new, even without a prompt. + assert.deepEqual(await readdir(f.root), ['agent.cjs']); + const result = await f.executor.execute(f.request('first-task'), f.context()); + assert.equal(result.status, 'completed'); + assert.equal( + JSON.parse(await readFile(join(f.root, '.acp-fixture-history.json'), 'utf8')).length, + 2, + ); + } finally { + await f.dispose(); + } +}); + test('real stdio retains a conversation and returns original question option ids', async () => { const f = await fixture(); try { diff --git a/packages/acp-executor-plugin/src/acp-catalog.ts b/packages/acp-executor-plugin/src/acp-catalog.ts new file mode 100644 index 00000000000..8b4dc1fd103 --- /dev/null +++ b/packages/acp-executor-plugin/src/acp-catalog.ts @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { ExecutorCatalogEntry } from '@maka/core/executor-catalog'; +import type { AdmissionLimiter } from '@maka/runtime/admission-limiter'; + +const CATALOG_TTL_MS = 60_000; +const PROBE_TIMEOUT_MS = 30_000; + +interface PendingProbe { + revision: number; + controller: AbortController; + promise: Promise; +} + +/** Instance-scoped candidates. Only a retained task Session owns workspace configuration. */ +export class AcpCatalog { + #cached?: { entry: ExecutorCatalogEntry; expires: number }; + #pending?: PendingProbe; + #tail?: Promise; + readonly #operations = new Set>(); + #revision = 0; + #disposed = false; + + constructor( + private readonly admission: AdmissionLimiter, + private readonly probe: (signal: AbortSignal) => Promise, + private readonly unavailable: () => ExecutorCatalogEntry, + ) {} + + async get(signal: AbortSignal, refresh = false): Promise { + signal.throwIfAborted(); + if (this.#disposed) return this.unavailable(); + if (refresh) this.invalidate(); + while (true) { + signal.throwIfAborted(); + if (this.#disposed) return this.unavailable(); + const cached = this.#cached; + if (cached && cached.expires > Date.now()) return cached.entry; + const pending = this.#ensureProbe(); + const result = await waitForSignal(pending.promise, signal); + signal.throwIfAborted(); + if (this.#disposed) return this.unavailable(); + // An invalidation need not have another caller to start its replacement. + // Only superseded revisions retry; genuine failures remain terminal. + if (pending.revision !== this.#revision) continue; + return result; + } + } + + #ensureProbe(): PendingProbe { + if (!this.#pending) { + const revision = this.#revision; + const controller = new AbortController(); + const startup = AbortSignal.any([controller.signal, AbortSignal.timeout(PROBE_TIMEOUT_MS)]); + const previous = this.#tail; + const run = async () => { + try { + // A refresh must drain the superseded process before starting its replacement. + await previous; + startup.throwIfAborted(); + const permit = await this.admission.acquire(startup); + let entry: ExecutorCatalogEntry; + try { + startup.throwIfAborted(); + // probe settles only after its process and temporary directory have been disposed. + entry = await this.probe(startup); + } finally { + permit.release(); + } + if (startup.aborted || revision !== this.#revision || this.#disposed) + return this.unavailable(); + if (entry.readiness === 'ready') + this.#cached = { entry, expires: Date.now() + CATALOG_TTL_MS }; + return entry; + } catch { + return this.unavailable(); + } + }; + const promise = run().finally(() => { + this.#operations.delete(promise); + if (this.#pending?.promise === promise) this.#pending = undefined; + if (this.#tail === promise) this.#tail = undefined; + }); + this.#pending = { revision, controller, promise }; + this.#tail = promise; + this.#operations.add(promise); + } + return this.#pending; + } + + invalidate(): void { + this.#revision++; + this.#cached = undefined; + this.#pending?.controller.abort(new Error('ACP catalog invalidated')); + this.#pending = undefined; + } + + async dispose(): Promise { + this.#disposed = true; + this.invalidate(); + await Promise.allSettled(this.#operations); + } +} + +/** A caller can stop waiting without cancelling a probe shared by other drafts. */ +async function waitForSignal(promise: Promise, signal: AbortSignal): Promise { + signal.throwIfAborted(); + return await new Promise((resolve, reject) => { + const aborted = () => reject(signal.reason); + signal.addEventListener('abort', aborted, { once: true }); + void promise.then( + (value) => { + signal.removeEventListener('abort', aborted); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted); + reject(error); + }, + ); + }); +} diff --git a/packages/acp-executor-plugin/src/index.ts b/packages/acp-executor-plugin/src/index.ts index af24887252a..802ba4af15c 100644 --- a/packages/acp-executor-plugin/src/index.ts +++ b/packages/acp-executor-plugin/src/index.ts @@ -19,10 +19,12 @@ import { createHash } from 'node:crypto'; import { constants, createReadStream } from 'node:fs'; -import { access, mkdtemp, rm, realpath, stat } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, realpath, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; +import { AdmissionLimiter } from '@maka/runtime/admission-limiter'; +import { AcpCatalog } from './acp-catalog.js'; import type { ExecutorCatalogEntry, ExecutorConfiguration } from '@maka/core/executor-catalog'; -import { dirname, isAbsolute, resolve } from 'node:path'; +import { dirname, isAbsolute, join, resolve } from 'node:path'; import { methods, type ClientApp, @@ -34,6 +36,7 @@ import { type ToolCallContent, type ToolCallUpdate, } from '@agentclientprotocol/sdk'; +import { pluginIdentity } from '@maka/runtime/plugin-runtime'; import type { PluginStorageService } from '@maka/runtime/plugin-data-services'; import type { PluginExecutorContext, @@ -120,6 +123,7 @@ export interface AcpContinuityRecord { readonly sessionId?: string; readonly pendingTurnId?: string; readonly confirmedModel?: string; + readonly confirmedMode?: string; readonly gapEvidence?: { readonly replayedUpdates: number; readonly replayedUserChunks: number; @@ -151,6 +155,7 @@ interface RetainedSession { readonly conversationKey: string; readonly configuration?: ExecutorConfiguration; readonly cwd: string; + restoredFromState?: boolean; owner?: AcpConnectionOwner; connection?: ClientConnection; acpSessionId?: string; @@ -169,6 +174,11 @@ interface RetainedSession { loss?: Promise; } +type CatalogDirectory = ( + signal: AbortSignal, + use: (directory: string) => Promise, +) => Promise; + export class AcpExecutor implements PluginExecutorProvider { readonly id: string; readonly displayName: string; @@ -179,8 +189,9 @@ export class AcpExecutor implements PluginExecutorProvider { readonly #state?: AcpConversationStateStore; readonly #sessions = new Map(); #disposed = false; - #catalog?: ExecutorCatalogEntry; - #discovery?: Promise; + readonly #catalog: AcpCatalog; + readonly #withCatalogDirectory: CatalogDirectory; + #temporaryDirectory?: Promise; constructor( adapter: AcpAgentAdapter, @@ -188,6 +199,8 @@ export class AcpExecutor implements PluginExecutorProvider { options: { readonly createConnection?: AcpConnectionFactory; readonly state?: AcpConversationStateStore; + readonly catalogAdmission?: AdmissionLimiter; + readonly withCatalogDirectory?: CatalogDirectory; } = {}, ) { this.#adapter = validateAdapter(adapter); @@ -196,41 +209,68 @@ export class AcpExecutor implements PluginExecutorProvider { this.#configured = validateConfiguredAgent(adapter.configure(config)); this.#createConnection = options.createConnection ?? createAcpConnection; this.#state = options.state; + this.#withCatalogDirectory = + options.withCatalogDirectory ?? + (async (signal, use) => { + // Standalone embeddings have no Host storage; keep their cwd stable for + // this executor's lifetime. Registered adapters use durable scratch paths. + signal.throwIfAborted(); + this.#temporaryDirectory ??= mkdtemp(join(tmpdir(), 'maka-acp-catalog-')); + const directory = await this.#temporaryDirectory; + await mkdir(directory, { recursive: true, mode: 0o700 }); + try { + return await use(await realpath(directory)); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + this.#catalog = new AcpCatalog( + options.catalogAdmission ?? new AdmissionLimiter(1), + (signal) => this.#probeCatalog(signal), + () => this.#catalogEntry('unavailable'), + ); } - async discover(input: { cwd: string; signal: AbortSignal }): Promise { - if (this.#disposed) return this.#catalogEntry('unavailable'); - if (this.#catalog) return this.#catalog; - if (this.#discovery) return this.#discovery; - const probe = async () => { - // A bounded disposable ACP probe never creates a Maka task or joins the retained-session map. - const cwd = await mkdtemp(resolve(tmpdir(), 'maka-acp-catalog-')); - const session: RetainedSession = { + async discover(input: { + cwd: string; + signal: AbortSignal; + refresh?: boolean; + }): Promise { + // The generic Host query still supplies its target cwd. ACP draft candidates + // belong to this configured executor, and never initialize the user's workspace. + return await this.#catalog.get(input.signal, input.refresh); + } + + async #probeCatalog(signal: AbortSignal): Promise { + return await this.#withCatalogDirectory(signal, (directory) => + this.#readCatalog(directory, signal), + ); + } + + async #readCatalog(directory: string, signal: AbortSignal): Promise { + let session: RetainedSession | undefined; + try { + signal.throwIfAborted(); + session = { conversationKey: 'catalog-probe', - cwd, + cwd: await realpath(directory), configOptions: [], lost: false, }; - try { - await this.#initialize(session, input.signal, true); - const result = this.#catalogEntry('ready', session.configOptions); - this.#catalog = result; - return result; - } catch (error) { - return this.#catalogEntry( - (error as { code?: unknown })?.code === -32000 - ? 'authentication_required' - : 'unavailable', - ); - } finally { - await this.#disposeSession(session); - await rm(cwd, { recursive: true, force: true }); - } - }; - this.#discovery = probe().finally(() => { - this.#discovery = undefined; - }); - return this.#discovery; + await this.#initialize(session, signal, true); + return this.#catalogEntry('ready', session.configOptions); + } catch (error) { + return this.#catalogEntry( + isAuthenticationFailure(error) ? 'authentication_required' : 'unavailable', + ); + } finally { + // The directory lease outlives process cleanup, including failure cleanup. + if (session) await (session.loss ?? this.#disposeSession(session)); + } + } + + invalidateCatalog(): void { + this.#catalog.invalidate(); } async inspectConversation(input: { @@ -241,53 +281,72 @@ export class AcpExecutor implements PluginExecutorProvider { const session = this.#sessions.get(input.conversationKey); if (this.#disposed) return this.#catalogEntry('unavailable'); if (session && session.cwd !== resolve(input.cwd)) - return this.#catalogEntry('history_only', [], input.configuration?.model); + return this.#catalogEntry('history_only', [], input.configuration); if (session?.restoring) - return this.#catalogEntry('restoring', session.configOptions, input.configuration?.model); + return this.#catalogEntry('restoring', session.configOptions, input.configuration); if (session?.restoreFailed) - return this.#catalogEntry( - 'restore_failed', - session.configOptions, - input.configuration?.model, - ); + return this.#catalogEntry('restore_failed', session.configOptions, input.configuration); if (session?.historyGap) - return this.#catalogEntry('history_gap', session.configOptions, input.configuration?.model); + return this.#catalogEntry('history_gap', session.configOptions, input.configuration); if (session && !session.lost) - return this.#catalogEntry('ready', session.configOptions, input.configuration?.model); + return this.#catalogEntry('ready', session.configOptions, input.configuration); if (session?.lost && !session.record) - return this.#catalogEntry('history_only', session.configOptions, input.configuration?.model); + return this.#catalogEntry('history_only', session.configOptions, input.configuration); const stored = this.#state?.read ? decodeContinuity(await this.#state.read(input.conversationKey)) : (await this.#state?.has(input.conversationKey, input.cwd)) ? 'legacy' : undefined; - if (!stored) return this.#catalogEntry('ready', [], input.configuration?.model); + if (!stored) return this.#catalogEntry('ready', [], input.configuration); if (stored === 'invalid' || stored === 'legacy' || stored.cwd !== resolve(input.cwd)) - return this.#catalogEntry('history_only', [], input.configuration?.model); + return this.#catalogEntry('history_only', [], input.configuration); if (stored.phase === 'reserved') - return this.#catalogEntry('history_only', [], input.configuration?.model); + return this.#catalogEntry('history_only', [], input.configuration); return this.#catalogEntry( stored.phase === 'prompt_pending' || stored.phase === 'history_gap' ? 'history_gap' : 'restorable', [], - input.configuration?.model ?? stored.confirmedModel, + { + model: input.configuration?.model ?? stored.confirmedModel, + mode: input.configuration?.mode ?? stored.confirmedMode, + }, ); } async configureConversation( input: { conversationKey: string; cwd: string; configuration?: ExecutorConfiguration }, signal: AbortSignal, - ): Promise { - if (!input.configuration?.model) throw new Error('ACP model change is unavailable'); + ): Promise { + if (!input.configuration?.model && !input.configuration?.mode) + throw new Error('ACP configuration change is unavailable'); const session = await this.#session(input); - if (session.active || session.configuring) throw new Error('ACP model change is unavailable'); + if (session.active || session.configuring || session.restoring || session.awaitingAck) + throw new Error('ACP configuration change is unavailable while the Session is busy'); session.configuring = true; const wasConnected = !!session.connection; try { - await this.#ensureInitialized(session, signal); - await this.#applyInitialConfig(session, { model: input.configuration.model }, signal, true); + // A fresh Session must select the requested model before checking which + // modes that model exposes. Do not apply the launch defaults separately. + await this.#ensureInitialized(session, signal, true); + await this.#applyInitialConfig( + session, + !wasConnected && !session.restoredFromState + ? { ...this.#configured.launch.initialConfig, ...input.configuration } + : input.configuration, + signal, + true, + ); + return { + ...(currentAcpModel(session.configOptions) + ? { model: currentAcpModel(session.configOptions) } + : {}), + ...(acpOption(session.configOptions, 'mode')?.currentValue + ? { mode: acpOption(session.configOptions, 'mode')!.currentValue } + : {}), + }; } catch (error) { + if (isAuthenticationFailure(error)) this.invalidateCatalog(); if (!wasConnected || session.lost) { await this.#lose(session); if (session.record?.sessionId && !session.historyGap) session.restoreFailed = true; @@ -304,7 +363,7 @@ export class AcpExecutor implements PluginExecutorProvider { #catalogEntry( readiness: ExecutorCatalogEntry['readiness'], options: readonly SessionConfigOption[] = [], - selected?: string, + selected?: ExecutorConfiguration, ): ExecutorCatalogEntry { const model = options.find( (option) => @@ -315,7 +374,20 @@ export class AcpExecutor implements PluginExecutorProvider { ? model.options .flatMap((entry) => ('options' in entry ? entry.options : [entry])) .map((entry) => ({ id: entry.value, name: entry.name })) - : (this.#catalog?.models ?? []); + : selected?.model + ? [{ id: selected.model, name: selected.model }] + : []; + const mode = options.find( + (option) => option.type === 'select' && (option.category === 'mode' || option.id === 'mode'), + ); + const modes = + mode?.type === 'select' + ? mode.options + .flatMap((entry) => ('options' in entry ? entry.options : [entry])) + .map((entry) => ({ id: entry.value, name: entry.name })) + : selected?.mode + ? [{ id: selected.mode, name: selected.mode }] + : []; return { id: this.id, displayName: this.displayName, @@ -324,13 +396,19 @@ export class AcpExecutor implements PluginExecutorProvider { ? (this.#adapter.describeModels?.(models) ?? { models }) : { models, - ...(this.#catalog?.modelGroups ? { modelGroups: this.#catalog.modelGroups } : {}), }), ...(model?.type === 'select' ? { currentModel: model.currentValue } - : selected - ? { currentModel: selected } + : selected?.model + ? { currentModel: selected.model } : {}), + ...(mode?.type === 'select' + ? { modes, currentMode: mode.currentValue, supportsModeChange: true } + : { + modes, + ...(selected?.mode ? { currentMode: selected.mode } : {}), + supportsModeChange: false, + }), supportsAttachments: false, supportsModelChange: model?.type === 'select', }; @@ -356,7 +434,7 @@ export class AcpExecutor implements PluginExecutorProvider { return failure('Conflicting executor models', 'acp_config_invalid'); const model = request.configuration?.model ?? (request.model === this.id ? undefined : request.model); - if (model) request = { ...request, configuration: { model } }; + if (model) request = { ...request, configuration: { ...request.configuration, model } }; let session: RetainedSession; try { session = await this.#session(request); @@ -370,12 +448,8 @@ export class AcpExecutor implements PluginExecutorProvider { try { await this.#ensureInitialized(session, context.signal); context.signal.throwIfAborted(); - if (request.configuration?.model) - await this.#applyInitialConfig( - session, - { model: request.configuration.model }, - context.signal, - ); + if (request.configuration?.model || request.configuration?.mode) + await this.#applyInitialConfig(session, request.configuration, context.signal); await this.#beginPrompt(session, request.turnId); const prompt = session.connection!.agent.request(methods.agent.session.prompt, { sessionId: session.acpSessionId!, @@ -401,6 +475,7 @@ export class AcpExecutor implements PluginExecutorProvider { } return { status: 'completed', text: active.text }; } catch (error) { + if (isAuthenticationFailure(error)) this.invalidateCatalog(); if (errorCode(error) === 'acp_history_gap') session.historyGap = true; await this.#lose(session); if (session.record?.sessionId && !session.historyGap) session.restoreFailed = true; @@ -435,6 +510,7 @@ export class AcpExecutor implements PluginExecutorProvider { async dispose(): Promise { if (this.#disposed) return; this.#disposed = true; + await this.#catalog.dispose(); const sessions = [...this.#sessions.values()]; this.#sessions.clear(); const settlements = await Promise.allSettled( @@ -479,9 +555,13 @@ export class AcpExecutor implements PluginExecutorProvider { return created; } - async #ensureInitialized(session: RetainedSession, signal: AbortSignal): Promise { + async #ensureInitialized( + session: RetainedSession, + signal: AbortSignal, + deferSelectedConfig = false, + ): Promise { if (session.initialization) return await session.initialization; - const initialization = this.#initialize(session, signal); + const initialization = this.#initialize(session, signal, false, deferSelectedConfig); session.initialization = initialization; try { await initialization; @@ -493,7 +573,12 @@ export class AcpExecutor implements PluginExecutorProvider { } } - async #initialize(session: RetainedSession, signal: AbortSignal, probe = false): Promise { + async #initialize( + session: RetainedSession, + signal: AbortSignal, + probe = false, + deferSelectedConfig = false, + ): Promise { const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS); const startupSignal = AbortSignal.any([signal, timeout]); startupSignal.throwIfAborted(); @@ -524,6 +609,7 @@ export class AcpExecutor implements PluginExecutorProvider { 'External Session history cannot be aligned with saved conversation events', 'acp_history_gap', ); + session.restoredFromState = !!stored; if (stored) { session.record = stored; session.acpSessionId = stored.sessionId; @@ -585,7 +671,34 @@ export class AcpExecutor implements PluginExecutorProvider { ]); session.configOptions = restored.configOptions ?? []; const restoredModel = currentAcpModel(session.configOptions); - if (restoredModel) await this.#persistConfirmedModel(session, restoredModel); + const restoredMode = acpOption(session.configOptions, 'mode')?.currentValue; + if ( + (stored.confirmedModel && restoredModel !== stored.confirmedModel) || + (stored.confirmedMode && restoredMode !== stored.confirmedMode) + ) { + const confirmed = await this.#applyInitialConfig( + session, + { + ...(stored.confirmedModel ? { model: stored.confirmedModel } : {}), + ...(stored.confirmedMode ? { mode: stored.confirmedMode } : {}), + }, + startupSignal, + false, + // Only an explicit model-only configuration can recover a removed + // saved mode. Ordinary execution still requires the exact saved pair. + deferSelectedConfig && !!session.configuration?.model && !session.configuration.mode, + ); + if ( + (confirmed.model && currentAcpModel(session.configOptions) !== confirmed.model) || + (confirmed.mode && + acpOption(session.configOptions, 'mode')?.currentValue !== confirmed.mode) + ) + throw new AcpRuntimeError( + 'Restored Agent configuration differs from the saved task', + 'acp_config_unconfirmed', + ); + } + await this.#persistConfirmedConfig(session); session.restoring = false; if (pending) { // The Agent may have progressed beyond Maka's last durable event. Replay @@ -648,14 +761,13 @@ export class AcpExecutor implements PluginExecutorProvider { }; await this.#state?.write?.(session.conversationKey, established); session.record = established; - const createdModel = currentAcpModel(session.configOptions); - if (createdModel) await this.#persistConfirmedModel(session, createdModel); + await this.#persistConfirmedConfig(session); } - if (!probe) { + if (!probe && !deferSelectedConfig) { await this.#applyInitialConfig( session, - session.configuration?.model - ? { model: session.configuration.model } + session.configuration?.model || session.configuration?.mode + ? { ...launch.initialConfig, ...session.configuration } : (launch.initialConfig ?? {}), startupSignal, ); @@ -726,83 +838,135 @@ export class AcpExecutor implements PluginExecutorProvider { async #applyInitialConfig( session: RetainedSession, - values: Readonly>, + values: ExecutorConfiguration | Readonly>, signal: AbortSignal, restoreOnFailure = false, - ): Promise { - for (const [key, value] of Object.entries(values)) { - const option = session.configOptions.find( - (candidate) => - candidate.type === 'select' && (candidate.id === key || candidate.category === key), - ); - if (!option || option.type !== 'select') + omitUnavailableMode = false, + ): Promise>> { + let target = Object.entries(values).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ); + const before = session.configOptions; + const selectedModel = target.find(([key]) => key === 'model')?.[1]; + const changingModel = + selectedModel !== undefined && acpOption(before, 'model')?.currentValue !== selectedModel; + const validate = (options: readonly SessionConfigOption[], key: string, value: string) => { + const option = acpOption(options, key); + if (!option) throw new AcpRuntimeError( `ACP configuration is unavailable: ${key}`, 'acp_config_unavailable', ); - const options = option.options.flatMap((entry) => + const candidates = option.options.flatMap((entry) => 'options' in entry ? entry.options : [entry], ); - if (!options.some((entry) => entry.value === value)) + if (!candidates.some((entry) => entry.value === value)) throw new AcpRuntimeError( `ACP configuration value is unavailable: ${key}`, 'acp_config_invalid', ); - if (option.currentValue === value) { - if (key === 'model') await this.#persistConfirmedModel(session, value); - continue; - } - signal.throwIfAborted(); - try { + }; + // A model change can add, remove or replace the mode option. Validate that + // dependent option against the Agent's post-model response instead. + for (const [key, value] of target) { + if (omitUnavailableMode && key === 'mode') continue; + if (!changingModel || key === 'model') validate(before, key, value); + } + const ordered = [ + ...target.filter(([key]) => key === 'model'), + ...target.filter(([key]) => key !== 'model'), + ]; + let mutationAttempted = false; + try { + for (const [key, value] of ordered) { + const option = acpOption(session.configOptions, key); + if ( + omitUnavailableMode && + key === 'mode' && + !option?.options + .flatMap((entry) => ('options' in entry ? entry.options : [entry])) + .some((entry) => entry.value === value) + ) { + target = target.filter(([candidate]) => candidate !== 'mode'); + continue; + } + // Every remaining value requires confirmation. Outside explicit recovery, + // a model change cannot waive a requested or saved mode. + validate(session.configOptions, key, value); + if (!option) throw new Error('Validated ACP option disappeared'); + if (option.currentValue === value) continue; + signal.throwIfAborted(); + mutationAttempted = true; const updated = await session.connection!.agent.request( methods.agent.session.setConfigOption, { sessionId: session.acpSessionId!, configId: option.id, value }, { cancellationSignal: signal }, ); - const confirmed = updated.configOptions.find((candidate) => candidate.id === option.id); - if (confirmed?.type !== 'select' || confirmed.currentValue !== value) + session.configOptions = updated.configOptions; + if (acpOption(updated.configOptions, key)?.currentValue !== value) throw new AcpRuntimeError( 'Agent did not confirm the selected configuration', 'acp_config_unconfirmed', ); - session.configOptions = updated.configOptions; - if (key === 'model') await this.#persistConfirmedModel(session, value); - } catch (error) { - // A rejected/unconfirmed mutation can have reached the Agent. Restore the - // previous real ID and require an acknowledgement before permitting retry. - let restored = false; - if (restoreOnFailure && !session.lost && !signal.aborted) { - try { + } + if ( + target.some(([key, value]) => acpOption(session.configOptions, key)?.currentValue !== value) + ) + throw new AcpRuntimeError( + 'Agent changed another selected configuration', + 'acp_config_unconfirmed', + ); + await this.#persistConfirmedConfig(session); + return Object.fromEntries(target); + } catch (error) { + let restored = false; + if (restoreOnFailure && !session.lost && !signal.aborted) { + try { + // A rejected response can still have changed the Agent. Restore the + // model before its dependent mode, then verify the original snapshot. + for (const key of ['model', 'mode']) { + const original = acpOption(before, key); + const current = acpOption(session.configOptions, key); + if ( + !original || + !current || + (!mutationAttempted && current.currentValue === original.currentValue) + ) + continue; const rollback = await session.connection!.agent.request( methods.agent.session.setConfigOption, - { sessionId: session.acpSessionId!, configId: option.id, value: option.currentValue }, + { + sessionId: session.acpSessionId!, + configId: current.id, + value: original.currentValue, + }, { cancellationSignal: AbortSignal.timeout(5_000) }, ); - const confirmed = rollback.configOptions.find( - (candidate) => candidate.id === option.id, - ); - if ( - confirmed?.type === 'select' && - confirmed.currentValue === option.currentValue && - !session.lost - ) { - session.configOptions = rollback.configOptions; - if (key === 'model') await this.#persistConfirmedModel(session, option.currentValue); - restored = true; - } - } catch { - /* Uncertain configuration remains history-only. */ + session.configOptions = rollback.configOptions; } + restored = ['model', 'mode'].every((key) => { + const original = acpOption(before, key); + return ( + !original || + acpOption(session.configOptions, key)?.currentValue === original.currentValue + ); + }); + } catch { + /* Uncertain configuration remains unavailable. */ } - if (!restored) await this.#lose(session); - throw error; } + if (!restored) await this.#lose(session); + throw error; } } - async #persistConfirmedModel(session: RetainedSession, model: string): Promise { - if (!session.record || session.record.confirmedModel === model) return; - const record = { ...session.record, confirmedModel: model }; + async #persistConfirmedConfig( + session: RetainedSession, + configOptions: readonly SessionConfigOption[] = session.configOptions, + ): Promise { + if (!session.record) return; + const record = withConfirmedConfiguration(session.record, configOptions); + if (record === session.record) return; await this.#state?.write?.(session.conversationKey, record); session.record = record; } @@ -854,7 +1018,11 @@ export class AcpExecutor implements PluginExecutorProvider { if (session.historyGap || session.lost) return; if (update.sessionUpdate === 'config_option_update') { // During our mutation, its response (or rollback response) is authoritative. - if (!session.configuring && !session.lost) session.configOptions = update.configOptions; + if (!session.configuring && !session.lost) { + session.configOptions = update.configOptions; + // Agent notifications report observed state. Only a confirmed response + // to our own configuration request may change the durable selection. + } return; } const active = session.active; @@ -995,11 +1163,40 @@ export class AcpExecutor implements PluginExecutorProvider { } function currentAcpModel(options: readonly SessionConfigOption[]): string | undefined { - const option = options.find( - (candidate) => - candidate.type === 'select' && (candidate.id === 'model' || candidate.category === 'model'), + return acpOption(options, 'model')?.currentValue; +} + +function withConfirmedConfiguration( + record: AcpContinuityRecord, + options: readonly SessionConfigOption[], +): AcpContinuityRecord { + const model = currentAcpModel(options); + const mode = acpOption(options, 'mode')?.currentValue; + const { confirmedModel, confirmedMode, ...base } = record; + if (confirmedModel === model && confirmedMode === mode) return record; + return { + ...base, + ...(model ? { confirmedModel: model } : {}), + ...(mode ? { confirmedMode: mode } : {}), + }; +} + +function isAuthenticationFailure(error: unknown): boolean { + return ( + (error as { code?: unknown })?.code === -32000 || + (error instanceof Error && + /authentication required|unauthorized/u.test(error.message.toLowerCase())) + ); +} + +function acpOption( + options: readonly SessionConfigOption[], + key: string, +): Extract | undefined { + return options.find( + (candidate): candidate is Extract => + candidate.type === 'select' && (candidate.id === key || candidate.category === key), ); - return option?.type === 'select' ? option.currentValue : undefined; } function toolTextContent(content: readonly ToolCallContent[]): string { @@ -1020,6 +1217,9 @@ function toolTextContent(content: readonly ToolCallContent[]): string { * immutable package generation. */ export class AcpRuntimeService { + // Shared by every adapter below this ACP runtime Entry, including queued probes. + readonly #catalogAdmission = new AdmissionLimiter(2); + constructor(ctx: Context) { ctx.provide('acp', this); } @@ -1031,8 +1231,12 @@ export class AcpRuntimeService { ): Disposable> { const storage = consumer.get('storage'); if (!storage) throw new Error('ACP continuity storage is unavailable'); + const directoryKey = `acp-catalog/${adapter.id}/${createHash('sha256').update(pluginIdentity(consumer).entryId).digest('hex')}`; const provider = new AcpExecutor(adapter as AcpAgentAdapter, config, { state: pluginStateStore(storage, adapter.id), + catalogAdmission: this.#catalogAdmission, + withCatalogDirectory: (signal, use) => + storage.withScratchDirectory(directoryKey, signal, use), }); consumer.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`); return consumer.executors.register(provider); @@ -1087,6 +1291,8 @@ function decodeContinuity(value: unknown): StoredContinuity { (typeof record.pendingTurnId !== 'string' || !record.pendingTurnId)) || (record.confirmedModel !== undefined && (typeof record.confirmedModel !== 'string' || !record.confirmedModel)) || + (record.confirmedMode !== undefined && + (typeof record.confirmedMode !== 'string' || !record.confirmedMode)) || (record.phase === 'reserved' && record.sessionId !== undefined) || (record.phase !== 'reserved' && !record.sessionId) || (record.phase === 'prompt_pending' && !record.pendingTurnId) || diff --git a/packages/core/src/__tests__/executor-catalog.test.ts b/packages/core/src/__tests__/executor-catalog.test.ts index 374de122851..c5ce6642ac6 100644 --- a/packages/core/src/__tests__/executor-catalog.test.ts +++ b/packages/core/src/__tests__/executor-catalog.test.ts @@ -19,7 +19,11 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { normalizeCatalogEntry, type ExecutorCatalogEntry } from '../executor-catalog.js'; +import { + isExecutorConfiguration, + normalizeCatalogEntry, + type ExecutorCatalogEntry, +} from '../executor-catalog.js'; const catalog: ExecutorCatalogEntry = { id: 'external', @@ -48,6 +52,37 @@ test('structured capabilities survive wire normalization with immutable exact re assert.ok(Object.isFrozen(output.modelGroups![0]!.variants[0])); assert.notEqual(output.modelGroups, catalog.modelGroups); }); +test('opaque modes round-trip through generic configuration and catalog validation', () => { + assert.equal(isExecutorConfiguration({ model: 'opaque-high', mode: 'ask' }), true); + assert.equal(isExecutorConfiguration({ mode: 'ask', unknown: true }), false); + assert.equal(isExecutorConfiguration({ mode: 'bad\nvalue' }), false); + const entry = normalizeCatalogEntry( + { + ...catalog, + modes: [ + { id: 'ask', name: 'Ask' }, + { id: 'auto', name: 'Automatic' }, + ], + currentMode: 'ask', + supportsModeChange: true, + }, + 'external', + ); + assert.deepEqual( + entry.modes?.map((mode) => mode.id), + ['ask', 'auto'], + ); + assert.ok(Object.isFrozen(entry.modes?.[0])); + assert.throws(() => + normalizeCatalogEntry({ ...entry, modes: [entry.modes![0]!, entry.modes![0]!] }, 'external'), + ); + assert.throws(() => + normalizeCatalogEntry( + { ...entry, modes: [{ name: 'Missing id' }] } as unknown as ExecutorCatalogEntry, + 'external', + ), + ); +}); for (const readiness of ['restorable', 'restoring', 'restore_failed', 'history_gap'] as const) test(`restoration readiness survives executor catalog normalization: ${readiness}`, () => { assert.equal( diff --git a/packages/core/src/executor-catalog.ts b/packages/core/src/executor-catalog.ts index 5d821d7083e..08d11330fad 100644 --- a/packages/core/src/executor-catalog.ts +++ b/packages/core/src/executor-catalog.ts @@ -24,6 +24,8 @@ import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js'; /** Provider-owned choices; no model Connection or external protocol identity crosses this seam. */ export interface ExecutorConfiguration { readonly model?: string; + /** Opaque provider mode ID. Omission leaves the Agent's default unchanged. */ + readonly mode?: string; } export interface ExecutorSelection { @@ -47,6 +49,11 @@ export interface ExecutorModelChoice { readonly providerType?: ProviderType; } +export interface ExecutorModeChoice { + readonly id: string; + readonly name: string; +} + /** Presentation capability only: every variant references a real catalog model ID. */ export interface ExecutorModelGroup { readonly id: string; @@ -61,20 +68,28 @@ export interface ExecutorCatalogEntry { readonly models: readonly ExecutorModelChoice[]; readonly modelGroups?: readonly ExecutorModelGroup[]; readonly currentModel?: string; + readonly modes?: readonly ExecutorModeChoice[]; + readonly currentMode?: string; readonly supportsAttachments: boolean; readonly supportsModelChange: boolean; + readonly supportsModeChange?: boolean; } export function isExecutorConfiguration(value: unknown): value is ExecutorConfiguration { if (!value || typeof value !== 'object' || Array.isArray(value)) return false; const record = value as Record; return ( - Object.keys(record).every((key) => key === 'model') && + Object.keys(record).every((key) => key === 'model' || key === 'mode') && (record.model === undefined || (typeof record.model === 'string' && record.model.length > 0 && record.model.length <= 1024 && - !/[\0\r\n]/u.test(record.model))) + !/[\0\r\n]/u.test(record.model))) && + (record.mode === undefined || + (typeof record.mode === 'string' && + record.mode.length > 0 && + record.mode.length <= 1024 && + !/[\0\r\n]/u.test(record.mode))) ); } @@ -109,8 +124,21 @@ export function normalizeCatalogEntry( ) || new Set(value.models.map((model) => model.id)).size !== value.models.length || !isExecutorConfiguration({ model: value.currentModel }) || + (value.modes !== undefined && + (!Array.isArray(value.modes) || + value.modes.length > 64 || + !value.modes.every( + (mode) => + mode && + typeof mode.id === 'string' && + isExecutorConfiguration({ mode: mode.id }) && + isCatalogText(mode.name), + ) || + new Set(value.modes.map((mode) => mode.id)).size !== value.modes.length)) || + !isExecutorConfiguration({ mode: value.currentMode }) || typeof value.supportsAttachments !== 'boolean' || - typeof value.supportsModelChange !== 'boolean' + typeof value.supportsModelChange !== 'boolean' || + (value.supportsModeChange !== undefined && typeof value.supportsModeChange !== 'boolean') ) throw new TypeError('Executor catalog is invalid'); const usedModels = new Set(); @@ -180,8 +208,15 @@ export function normalizeCatalogEntry( } : {}), ...(value.currentModel !== undefined ? { currentModel: value.currentModel } : {}), + ...(value.modes !== undefined + ? { modes: Object.freeze(value.modes.map(({ id, name }) => Object.freeze({ id, name }))) } + : {}), + ...(value.currentMode !== undefined ? { currentMode: value.currentMode } : {}), supportsAttachments: value.supportsAttachments, supportsModelChange: value.supportsModelChange, + ...(value.supportsModeChange !== undefined + ? { supportsModeChange: value.supportsModeChange } + : {}), }); } diff --git a/packages/runtime-host/package.json b/packages/runtime-host/package.json index e51f2b1a7f7..90912442303 100644 --- a/packages/runtime-host/package.json +++ b/packages/runtime-host/package.json @@ -6,6 +6,7 @@ "type": "module", "private": true, "exports": { + "./antigravity-program": "./dist/antigravity-program.js", "./adapter": "./dist/adapter/index.js", "./protocol": "./dist/protocol/index.js", "./client": "./dist/client/index.js", diff --git a/packages/runtime-host/src/__tests__/external-agent-setup-coordinator.test.ts b/packages/runtime-host/src/__tests__/external-agent-setup-coordinator.test.ts index 39cad24e6c3..b1300a6d59f 100644 --- a/packages/runtime-host/src/__tests__/external-agent-setup-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/external-agent-setup-coordinator.test.ts @@ -276,6 +276,31 @@ test('install admits an empty saved path, reports progress and checks without au await coordinator.close(); } }); + +test('successful login invalidates catalog even when the executable setting is unchanged', async () => { + let invalidations = 0; + const coordinator = new HostExternalAgentSetupCoordinator({ + readPolicy: async () => policy, + platform: 'darwin', + arch: 'arm64', + acquireResidency: () => ({ release() {} }), + onCleanupFailure() {}, + onSucceeded: () => { + invalidations++; + }, + capabilities: { callService: async () => ({ kind: 'presented' }) }, + run: async () => {}, + }); + try { + await coordinator.handlers['external_agents.setup.start'](input, context); + for (let i = 0; i < 30 && (await projection(coordinator)).phase !== 'succeeded'; i++) + await new Promise((resolve) => setTimeout(resolve, 5)); + assert.equal((await projection(coordinator)).phase, 'succeeded'); + assert.equal(invalidations, 1); + } finally { + await coordinator.close(); + } +}); test('installation projection rejects invented progress, phase and output paths', () => { const base = { ...input, diff --git a/packages/runtime-host/src/__tests__/fixtures/plugin-scratch-holder.ts b/packages/runtime-host/src/__tests__/fixtures/plugin-scratch-holder.ts new file mode 100644 index 00000000000..efdcdc2e879 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/plugin-scratch-holder.ts @@ -0,0 +1,38 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { join } from 'node:path'; +import { writeFile } from 'node:fs/promises'; +import { HostPluginDataRuntime } from '../../server/plugin-data-runtime.js'; + +// Keep the IPC channel alive while the lease callback is deliberately suspended. +process.on('message', () => undefined); + +await new HostPluginDataRuntime(process.argv[2]!).withScratchDirectory( + { extensionId: 'fixture.extension', scopeId: 'session:test' }, + 'catalog', + new AbortController().signal, + async (path) => { + await writeFile(join(path, 'owned'), 'synthetic'); + process.send!({ path }); + await new Promise(() => { + /* Parent terminates this lock owner. */ + }); + }, +); diff --git a/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts b/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts index b7ed14ec450..4b9735b773a 100644 --- a/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts +++ b/packages/runtime-host/src/__tests__/plugin-data-runtime.test.ts @@ -18,10 +18,15 @@ */ import assert from 'node:assert/strict'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { fork } from 'node:child_process'; +import { once } from 'node:events'; +import { setTimeout as delay } from 'node:timers/promises'; +import { Context } from '@maka/runtime/plugin-kernel'; +import { PluginStorageService } from '@maka/runtime/plugin-data-services'; import { HostPluginDataRuntime } from '../server/plugin-data-runtime.js'; const namespace = Object.freeze({ extensionId: 'fixture.extension', scopeId: 'session:test' }); @@ -87,3 +92,194 @@ async function findJson(root: string): Promise { await visit(root); return output; } + +test('scratch paths survive runtime replacement and clear Agent startup writes', async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-scratch-')); + const signal = new AbortController().signal; + let firstPath = ''; + try { + await new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (path) => { + firstPath = path; + assert.deepEqual(await readdir(path), []); + await writeFile(join(path, 'agent-history'), 'synthetic'); + }, + ); + await assert.rejects(stat(firstPath), { code: 'ENOENT' }); + await new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (path) => { + assert.equal(path, firstPath); + assert.deepEqual(await readdir(path), []); + }, + ); + for (const different of [ + { ...namespace, scopeId: 'session:other' }, + { ...namespace, extensionId: 'other.extension' }, + ]) { + await new HostPluginDataRuntime(root).withScratchDirectory( + different, + 'catalog', + signal, + async (path) => { + assert.notEqual(path, firstPath); + }, + ); + } + await new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'other-adapter', + signal, + async (path) => { + assert.notEqual(path, firstPath); + }, + ); + await assert.rejects( + new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (path) => { + await writeFile(join(path, 'partial'), 'synthetic'); + throw new Error('initialization failed'); + }, + ), + /initialization failed/, + ); + await assert.rejects(stat(firstPath), { code: 'ENOENT' }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test('scratch leases serialize runtime instances and cancellation cannot clean another owner', { + timeout: 10_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-scratch-lock-')); + let release!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + let announce!: () => void; + const started = new Promise((resolve) => { + announce = resolve; + }); + const signal = new AbortController().signal; + const first = new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (path) => { + await writeFile(join(path, 'owned'), 'synthetic'); + announce(); + await hold; + assert.equal(await readFile(join(path, 'owned'), 'utf8'), 'synthetic'); + return path; + }, + ); + try { + await started; + const controller = new AbortController(); + const cancelled = new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + controller.signal, + async () => { + assert.fail('a cancelled waiter must not use the directory'); + }, + ); + const checked = assert.rejects(cancelled, /cancelled|AbortError/); + controller.abort(new Error('cancelled')); + await checked; + let entered = false; + const second = new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (path) => { + entered = true; + assert.deepEqual(await readdir(path), []); + return path; + }, + ); + await delay(50); + assert.equal(entered, false, 'a live owner excludes another runtime instance'); + release(); + assert.equal(await second, await first); + } finally { + release(); + await first; + await rm(root, { recursive: true, force: true }); + } +}); + +test('a crashed scratch owner releases its native lease and the next process cleans residue', { + timeout: 10_000, +}, async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-plugin-scratch-crash-')); + const child = fork(new URL('./fixtures/plugin-scratch-holder.js', import.meta.url), [root], { + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + }); + try { + const [message] = await once(child, 'message'); + const path = (message as { path: string }).path; + assert.equal(await readFile(join(path, 'owned'), 'utf8'), 'synthetic'); + const signal = new AbortController().signal; + let entered = false; + const next = new HostPluginDataRuntime(root).withScratchDirectory( + namespace, + 'catalog', + signal, + async (directory) => { + entered = true; + assert.equal(directory, path); + assert.deepEqual(await readdir(directory), []); + }, + ); + await delay(50); + assert.equal(entered, false, 'a live child process owns the directory'); + assert.equal(await readFile(join(path, 'owned'), 'utf8'), 'synthetic'); + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + await next; + assert.equal(entered, true); + } finally { + if (child.exitCode === null && child.signalCode === null) { + const exited = once(child, 'exit'); + child.kill('SIGKILL'); + await exited; + } + await rm(root, { recursive: true, force: true }); + } +}); + +test('Plugin storage scratch calls preserve the consumer namespace through the public service', async () => { + const directory = await mkdtemp(join(tmpdir(), 'maka-plugin-scratch-service-')); + const root = new Context(); + const storage = new PluginStorageService(root); + storage.bindRuntime(new HostPluginDataRuntime(directory)); + try { + const paths: string[] = []; + for (const scope of ['profile', 'session:test']) { + const consumer = root.extend({ + maka: { rootId: scope, packageId: 'fixture', entryId: 'adapter', generation: 1 }, + }); + await consumer + .get('storage')! + .withScratchDirectory('catalog', new AbortController().signal, async (path) => { + paths.push(path); + assert.deepEqual(await readdir(path), []); + }); + } + assert.notEqual(paths[0], paths[1]); + } finally { + await root.fiber.dispose(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 98692b175f0..a59cdbfffff 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -448,6 +448,20 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 79); }); + test('decodes optional executor catalog refresh without weakening exact keys', () => { + const decode = HOST_OPERATION_SPECS['plugin.executor.query'].decodeInput; + const catalogQuery = { kind: 'catalog', cwd: '/workspace' } as const; + + assert.deepEqual(decode(catalogQuery), catalogQuery); + assert.deepEqual(decode({ ...catalogQuery, refresh: false }), catalogQuery); + assert.deepEqual(decode({ ...catalogQuery, refresh: true }), { + ...catalogQuery, + refresh: true, + }); + assert.throws(() => decode({ ...catalogQuery, refresh: 'true' }), isInvalidFrame); + assert.throws(() => decode({ ...catalogQuery, unknown: true }), isInvalidFrame); + }); + test('publishes a new compatibility epoch for the optional conversation-copy sourceTurnId', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 99); }); diff --git a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts index 3e4a90dab71..f0479851d81 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-coordinator.test.ts @@ -25,6 +25,9 @@ import { } from '../protocol/index.js'; import assert from 'node:assert/strict'; +import type { ExecutorConfiguration } from '@maka/core/executor-catalog'; +import { Context } from '@maka/runtime/plugin-kernel'; +import { PluginExecutorService } from '@maka/runtime/plugin-executor-service'; import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -919,14 +922,14 @@ test('plugin executor creation bypasses model resolution and persists the execut executorId: 'codex', executorModel: 'gpt-codex', thinkingLevel: 'high', - executorConfig: { model: 'gpt-codex' }, + executorConfig: { model: 'gpt-codex', mode: 'auto' }, }, context, ); assert.equal(outcome.ok, true, JSON.stringify(outcome)); assert.equal(persistedInput?.executorId, 'codex'); - assert.deepEqual(persistedInput?.executorConfig, { model: 'gpt-codex' }); + assert.deepEqual(persistedInput?.executorConfig, { model: 'gpt-codex', mode: 'auto' }); assert.equal(persistedInput?.llmConnectionId, undefined); assert.equal(persistedInput?.llmConnectionSlug, 'executor:codex'); assert.equal(persistedInput?.model, 'gpt-codex'); @@ -2157,6 +2160,7 @@ function createFixture( readonly header?: Partial; readonly assertExecutorAvailable?: HostSessionCatalogCoordinatorOptions['assertExecutorAvailable']; readonly configureExecutor?: HostSessionCatalogCoordinatorOptions['configureExecutor']; + readonly isTurnBusy?: HostSessionCatalogCoordinatorOptions['isTurnBusy']; readonly retireExecutor?: HostSessionCatalogCoordinatorOptions['retireExecutor']; } = {}, ) { @@ -2250,6 +2254,7 @@ function createFixture( drains += 1; }, ...(options.configureExecutor ? { configureExecutor: options.configureExecutor } : {}), + ...(options.isTurnBusy ? { isTurnBusy: options.isTurnBusy } : {}), ...(options.retireExecutor ? { retireExecutor: options.retireExecutor } : {}), ...(options.assertExecutorAvailable ? { assertExecutorAvailable: options.assertExecutorAvailable } @@ -2485,6 +2490,130 @@ test('executor model changes commit only after idle agent confirmation', async ( assert.equal(fixture.drainRequests(), 0); }); +test('mode-only changes retain the saved model and wait for Agent confirmation', async () => { + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: async (_header, config) => { + assert.deepEqual(config, { model: 'before', mode: 'auto' }); + assert.deepEqual(fixture.header().executorConfig, { model: 'before', mode: 'ask' }); + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { mode: 'auto' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(fixture.header().executorConfig, { model: 'before', mode: 'auto' }); +}); + +test('Agent-confirmed mode side effects are persisted with a model change', async () => { + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: async (_header, config) => { + assert.deepEqual(config, { model: 'after' }); + return { model: 'after', mode: 'auto' }; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { model: 'after' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(fixture.header().executorConfig, { model: 'after', mode: 'auto' }); +}); + +test('Agent-confirmed snapshots clear options removed by a model change', async () => { + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: async (_header, config) => { + assert.deepEqual(config, { model: 'after' }); + return { model: 'after' }; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { model: 'after' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(fixture.header().executorConfig, { model: 'after' }); +}); + +test('an explicit mode remains required when a model changes', async () => { + const configuration = { model: 'after', mode: 'ask' }; + let received: ExecutorConfiguration | undefined; + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: async (_header, config) => { + received = config; + throw new Error('Mode unavailable for the selected model'); + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: configuration }, + }, + context, + ); + assert.deepEqual(received, configuration); + assert.equal(outcome.ok, false); + assert.deepEqual(fixture.header().executorConfig, { model: 'before', mode: 'ask' }); +}); + +test('Agent-confirmed snapshots clear removed models from both executor routes', async () => { + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: async (_header, config) => { + assert.deepEqual(config, { model: 'before', mode: 'auto' }); + return { mode: 'auto' }; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { mode: 'auto' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(fixture.header().executorConfig, { mode: 'auto' }); + assert.equal(fixture.header().model, 'remote'); +}); + test('failed Session commit restores the confirmed executor model', async () => { const confirmed: string[] = []; const fixture = createFixture({ @@ -2580,6 +2709,62 @@ test('rejected or busy executor model changes preserve durable configuration wit } }); +test('idle executor changes remain available after failed or aborted turns', async () => { + for (const status of ['blocked', 'aborted'] as const) { + let confirmations = 0; + const fixture = createFixture({ + header: { + status, + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before' }, + }, + configureExecutor: async () => { + confirmations++; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { model: 'after' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.equal(confirmations, 1); + assert.equal(fixture.header().executorConfig?.model, 'after'); + } +}); + +test('executor changes reject running and waiting Sessions even without an active manager turn', async () => { + for (const status of ['running', 'waiting_for_user'] as const) { + let confirmations = 0; + const fixture = createFixture({ + header: { + status, + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before' }, + }, + configureExecutor: async () => { + confirmations++; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { model: 'after' } }, + }, + context, + ); + assert.equal(outcome.ok, false); + assert.equal(confirmations, 0); + assert.equal(fixture.header().executorConfig?.model, 'before'); + } +}); + test('native execution settings cannot mutate an external task', async () => { const fixture = createFixture({ header: { @@ -2654,3 +2839,166 @@ test('projects the archive time of an archived Session and drops one on an activ assert.equal(stray.isArchived, false); assert.equal(Object.hasOwn(stray, 'archivedAt'), false); }); + +for (const mode of ['auto', undefined]) + test(`Runtime preserves the Agent-confirmed mode ${mode ?? '(removed)'} through Host persistence`, async () => { + const root = new Context(); + const executors = new PluginExecutorService(root); + root + .extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'bridge', generation: 1 }, + }) + .executors.register({ + id: 'remote', + execute: async () => ({ status: 'completed', text: '' }), + configureConversation: async () => ({ model: 'after', ...(mode ? { mode } : {}) }), + }); + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + configureExecutor: (header, configuration) => + executors.configureConversation(header.id, 'remote', { + conversationKey: header.id, + cwd: header.cwd, + configuration, + }), + }); + try { + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { model: 'after' } }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(fixture.header().executorConfig, { + model: 'after', + ...(mode ? { mode } : {}), + }); + } finally { + await root.fiber.dispose(); + } + }); + +for (const key of ['model', 'mode'] as const) + test(`unconfirmed explicit ${key} is rejected before persistence`, async () => { + const before = { model: 'before', mode: 'ask' }; + const fixture = createFixture({ + header: { backend: 'plugin-executor', executorId: 'remote', executorConfig: before }, + configureExecutor: async () => before, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: key === 'model' ? { model: 'after' } : { mode: 'auto' } }, + }, + context, + ); + assert.equal(outcome.ok, false); + assert.deepEqual(fixture.header().executorConfig, before); + }); +test('queued turn blocks configuration before a running turn exists', async () => { + let calls = 0; + const fixture = createFixture({ + header: { + backend: 'plugin-executor', + executorId: 'remote', + executorConfig: { model: 'before', mode: 'ask' }, + }, + isTurnBusy: () => true, + configureExecutor: async () => { + calls++; + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { mode: 'auto' } }, + }, + context, + ); + assert.equal(outcome.ok, false); + assert.equal(calls, 0); +}); +test('creation validates the exact selected mode alongside its model', async () => { + let received: ExecutorConfiguration | undefined; + const fixture = createFixture({ + assertExecutorAvailable: (_session, _executor, config) => { + received = config; + return config; + }, + }); + const outcome = await fixture.coordinator.handlers['session.create']( + { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + executorId: 'remote', + executorConfig: { model: 'chosen', mode: 'auto' }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + assert.deepEqual(received, { model: 'chosen', mode: 'auto' }); +}); +test('changing the mode changes creation request identity', async () => { + const fingerprints: string[] = []; + const fixture = createFixture({ + stores: { + createStableSession: async (args) => { + fingerprints.push(args.requestFingerprint); + return { kind: 'existing', record: headerSnapshot(sessionHeader(args.sessionId, []), 1) }; + }, + }, + }); + for (const mode of ['ask', 'auto']) { + const outcome = await fixture.coordinator.handlers['session.create']( + { + sessionId: fixture.sessionId, + workspace: { kind: 'host_path', path: process.cwd() }, + executorId: 'remote', + executorConfig: { model: 'chosen', mode }, + }, + context, + ); + assert.equal(outcome.ok, true, JSON.stringify(outcome)); + } + assert.equal(fingerprints.length, 2); + assert.notEqual(fingerprints[0], fingerprints[1]); +}); +test('failed local commit restores a mode-only durable task', async () => { + const confirmed: ExecutorConfiguration[] = []; + let retired = false; + const fixture = createFixture({ + header: { backend: 'plugin-executor', executorId: 'remote', executorConfig: { mode: 'ask' } }, + configureExecutor: async (_header, config) => { + confirmed.push(config); + return config; + }, + retireExecutor: async () => { + retired = true; + }, + manager: { + transitionSessionConfiguration: async () => { + throw new SessionConfigurationTransitionError('operation_unavailable', 'Commit failed'); + }, + }, + }); + const outcome = await fixture.coordinator.handlers['session.configuration.update']( + { + sessionId: fixture.sessionId, + expectedRevision: fixture.revision(), + patch: { executorConfig: { mode: 'auto' } }, + }, + context, + ); + assert.equal(outcome.ok, false); + assert.deepEqual(confirmed, [{ mode: 'auto' }, { mode: 'ask' }]); + assert.equal(retired, false); +}); diff --git a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts index db411a174ef..c0124e39057 100644 --- a/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/session-catalog-protocol.test.ts @@ -837,7 +837,9 @@ test('executor configuration rejects ambiguous routes and malformed values', () null, { model: '' }, { model: 'bad\nvalue' }, - { mode: 'yolo' }, + { mode: '' }, + { mode: 'bad\nvalue' }, + { mode: 4 }, { model: 4 }, ]) { assert.throws( diff --git a/packages/runtime-host/src/antigravity-program.ts b/packages/runtime-host/src/antigravity-program.ts new file mode 100644 index 00000000000..e7f39e9ecf0 --- /dev/null +++ b/packages/runtime-host/src/antigravity-program.ts @@ -0,0 +1,56 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants } from 'node:fs'; +import { access, realpath, stat } from 'node:fs/promises'; +import { dirname, isAbsolute, join } from 'node:path'; + +export type AntigravityProgramFailure = 'executable_unavailable' | 'helper_unavailable'; + +/** Public diagnostics survive Electron IPC without exposing filesystem error details. */ +export class AntigravityProgramError extends Error { + constructor(readonly failure: AntigravityProgramFailure) { + super(`Antigravity program: ${failure}`); + } +} + +/** Shared by native program selection and the Host's check/sign-in admission. */ +export async function checkAntigravityProgram(executable: string): Promise<{ + executable: string; + helper: string; +}> { + const resolved = await checkedFile(executable, 'executable_unavailable'); + const helper = await checkedFile( + join(dirname(resolved), 'localharness_external'), + 'helper_unavailable', + ); + return { executable: resolved, helper }; +} + +async function checkedFile(path: string, failure: AntigravityProgramFailure): Promise { + try { + if (!isAbsolute(path)) throw new Error('Absolute path required'); + const resolved = await realpath(path); + if (!(await stat(resolved)).isFile()) throw new Error('File required'); + await access(resolved, constants.X_OK); + return resolved; + } catch { + throw new AntigravityProgramError(failure); + } +} diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index 27e6bffdc56..712f0c82c0d 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -104,7 +104,9 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 202 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 204 as const; +// 204: Executor catalogs and Session configuration carry opaque mode IDs; +// catalog queries may request a provider refresh. Older peers reject these fields. // 202: `session.remove.preview` takes a bounded list of Sessions and reports the // child tasks, worktrees and optionally the bytes their removal would delete; // `session.remove` takes `requireArchivedForMs` and may answer `too_recent`. diff --git a/packages/runtime-host/src/protocol/plugin-platform.ts b/packages/runtime-host/src/protocol/plugin-platform.ts index 518ed9a208f..16e2f23a4e9 100644 --- a/packages/runtime-host/src/protocol/plugin-platform.ts +++ b/packages/runtime-host/src/protocol/plugin-platform.ts @@ -102,7 +102,7 @@ export interface PluginPackageProjection { } export type PluginExecutorQueryInput = - | { readonly kind: 'catalog'; readonly cwd: string } + | { readonly kind: 'catalog'; readonly cwd: string; readonly refresh?: boolean } | { readonly kind: 'conversation'; readonly sessionId: string }; export interface PluginExecutorQueryResult { readonly items: readonly ExecutorCatalogEntry[]; @@ -342,8 +342,14 @@ export const PLUGIN_PLATFORM_OPERATION_SPECS = { decodeInput: (value) => { const record = requireRecord(value, 'Executor query'); if (record.kind === 'catalog') { - const input = requireExactRecord(record, 'Executor catalog', ['kind', 'cwd']); - return { kind: 'catalog', cwd: requireString(input.cwd, 'cwd', 4096) }; + const input = requireShapedRecord(record, 'Executor catalog', ['kind', 'cwd'], ['refresh']); + if (input.refresh !== undefined && typeof input.refresh !== 'boolean') + throw invalidProtocolFrame('Invalid executor refresh flag'); + return { + kind: 'catalog', + cwd: requireString(input.cwd, 'cwd', 4096), + ...(input.refresh ? { refresh: true } : {}), + }; } const input = requireExactRecord(record, 'Executor conversation', ['kind', 'sessionId']); if (input.kind !== 'conversation') throw invalidProtocolFrame('Invalid executor query'); diff --git a/packages/runtime-host/src/server/acp/antigravity.ts b/packages/runtime-host/src/server/acp/antigravity.ts index b0d033ee24b..ed0886bbe2f 100644 --- a/packages/runtime-host/src/server/acp/antigravity.ts +++ b/packages/runtime-host/src/server/acp/antigravity.ts @@ -17,13 +17,12 @@ * under the License. */ -import { constants } from 'node:fs'; -import { access, realpath, stat } from 'node:fs/promises'; -import { dirname, join, isAbsolute } from 'node:path'; +import { dirname } from 'node:path'; import { StringDecoder } from 'node:string_decoder'; import { methods, RequestError } from '@agentclientprotocol/sdk'; import type { ExternalAgentSetupAction } from '../../protocol/external-agent-setup.js'; import { AcpSetupError, withAcpConnection } from './connection.js'; +import { checkAntigravityProgram, AntigravityProgramError } from '../../antigravity-program.js'; const AUTH_PREFIX = 'Open the following link to authenticate the ACP server: '; const INITIALIZE_TIMEOUT_MS = 30_000; @@ -59,11 +58,7 @@ export async function runAntigravitySetup(input: { }); try { signal.throwIfAborted(); - const executable = await checkedFile(input.executable, 'executable_unavailable'); - const helper = await checkedFile( - join(dirname(executable), 'localharness_external'), - 'helper_unavailable', - ); + const { executable, helper } = await checkAntigravityProgram(input.executable); await withAcpConnection( { executable, @@ -117,6 +112,7 @@ export async function runAntigravitySetup(input: { }, ); } catch (error) { + if (error instanceof AntigravityProgramError) throw new AcpSetupError(error.failure); if (error instanceof AcpSetupError) throw error; signal.throwIfAborted(); throw new AcpSetupError('connection_failed'); @@ -125,21 +121,6 @@ export async function runAntigravitySetup(input: { } } -async function checkedFile( - path: string, - failure: 'executable_unavailable' | 'helper_unavailable', -): Promise { - try { - if (!isAbsolute(path)) throw new Error('Absolute path required'); - const resolved = await realpath(path); - if (!(await stat(resolved)).isFile()) throw new Error('File required'); - await access(resolved, constants.X_OK); - return resolved; - } catch { - throw new AcpSetupError(failure); - } -} - /** Official 1.1.1 prints its login link to stderr; ordinary diagnostics are discarded. */ export function createAntigravityStderrHandler(onAuthorizationUrl: (url: string) => void) { const recognize = (line: string): boolean => { diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index 6e0a2ccc164..d751ed9ae37 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -425,7 +425,7 @@ export async function createExecutionRuntimeHostComposition( ...(header.executorConfig ? { configuration: header.executorConfig } : {}), }); } - const catalog = await pluginExecutors.catalog({ cwd: input.cwd }); + const catalog = await pluginExecutors.catalog({ cwd: input.cwd, refresh: input.refresh }); return withBuiltinExternalAgentCatalog(catalog); }, ); @@ -1700,6 +1700,10 @@ export async function createExecutionRuntimeHostComposition( context.retainUntilProcessExit(); context.requestDrain(); }, + onSucceeded: () => { + pluginExecutors.invalidateCatalog(); + hostChanges.publishConfiguration(); + }, capabilities: clientCapabilities, }); oauth = new HostOAuthCoordinator({ @@ -2205,6 +2209,7 @@ export async function createExecutionRuntimeHostComposition( }); async function applyRuntimePolicyMutationEffects(): Promise { try { + pluginExecutors.invalidateCatalog(); await builtinExternalAgentPlugins.reconcile(); await requireMemory(memory).refreshAfterPolicyMutation(); } catch (error) { @@ -2228,9 +2233,10 @@ export async function createExecutionRuntimeHostComposition( continuity: continuityCoordinator, workspaceResolver, requestDrain: context.requestDrain, + isTurnBusy: (sessionId) => rootCoordinator?.hasActiveOrPendingTurn(sessionId) ?? false, configureExecutor: async (header, configuration) => { if (!header.executorId) throw new Error('Session has no executor'); - await pluginExecutors.configureConversation(header.id, header.executorId, { + return await pluginExecutors.configureConversation(header.id, header.executorId, { conversationKey: header.id, cwd: header.cwd, configuration, @@ -2247,12 +2253,19 @@ export async function createExecutionRuntimeHostComposition( if (!entry || entry.readiness !== 'ready') throw new Error('Executor is not ready'); // Catalog-managed executors pin their confirmed configuration on every create path. // Providers without model discovery retain main's executor-specific model contract. - if (entry.supportsModelChange || entry.models.length > 0) { + if ( + entry.supportsModelChange || + entry.models.length > 0 || + entry.supportsModeChange || + entry.modes?.length + ) { if ( configuration?.model && !entry.models.some((model) => model.id === configuration.model) ) throw new Error('Executor model is unavailable'); + if (configuration?.mode && !entry.modes?.some((mode) => mode.id === configuration.mode)) + throw new Error('Executor mode is unavailable'); return configuration ?? {}; } }, diff --git a/packages/runtime-host/src/server/external-agent-setup-coordinator.ts b/packages/runtime-host/src/server/external-agent-setup-coordinator.ts index db9f5eb91c9..5104f47f29a 100644 --- a/packages/runtime-host/src/server/external-agent-setup-coordinator.ts +++ b/packages/runtime-host/src/server/external-agent-setup-coordinator.ts @@ -61,6 +61,7 @@ export class HostExternalAgentSetupCoordinator { private readonly deps: { readPolicy(): Promise; onCleanupFailure(): void; + onSucceeded?(): void; acquireResidency(): OperationResidency; capabilities: Pick; install?(input: { @@ -172,6 +173,7 @@ export class HostExternalAgentSetupCoordinator { }); attempt.abort.signal.throwIfAborted(); attempt.projection = { ...attempt.projection, phase: 'succeeded', installedExecutable }; + this.deps.onSucceeded?.(); return; } await (this.deps.run ?? runAntigravitySetup)({ @@ -196,6 +198,7 @@ export class HostExternalAgentSetupCoordinator { } }, }); + if (!attempt.abort.signal.aborted) this.deps.onSucceeded?.(); attempt.projection = { ...attempt.projection, phase: attempt.abort.signal.aborted ? 'cancelled' : 'succeeded', diff --git a/packages/runtime-host/src/server/plugin-data-runtime.ts b/packages/runtime-host/src/server/plugin-data-runtime.ts index 4eecd968e95..aa72202fa77 100644 --- a/packages/runtime-host/src/server/plugin-data-runtime.ts +++ b/packages/runtime-host/src/server/plugin-data-runtime.ts @@ -20,6 +20,7 @@ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; +import { withPluginScratchDirectory } from './plugin-scratch-directory.js'; import type { PluginDataMutation, PluginDataNamespace, @@ -59,6 +60,21 @@ export class HostPluginDataRuntime implements PluginDataRuntime { this.#root = join(controlDirectory, 'plugin-data'); } + withScratchDirectory( + namespace: PluginDataNamespace, + key: string, + signal: AbortSignal, + use: (directory: string) => Promise, + ): Promise { + validateNamespace(namespace); + const identity = createHash('sha256').update(key).digest('hex'); + return withPluginScratchDirectory( + join(this.#root, digest(namespace), 'scratch', identity), + signal, + use, + ); + } + async read( namespace: PluginDataNamespace, domain: 'settings' | 'storage', diff --git a/packages/runtime-host/src/server/plugin-scratch-directory.ts b/packages/runtime-host/src/server/plugin-scratch-directory.ts new file mode 100644 index 00000000000..eddefbdea8f --- /dev/null +++ b/packages/runtime-host/src/server/plugin-scratch-directory.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { lstat, mkdir, realpath, rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { setTimeout as delay } from 'node:timers/promises'; +import { tryAcquireFileLifetimeOwner } from '@maka/storage/file-lifetime-owner'; + +/** Native leases cover separate runtime instances and are released by the OS on exit. */ +export async function withPluginScratchDirectory( + root: string, + signal: AbortSignal, + use: (directory: string) => Promise, +): Promise { + signal.throwIfAborted(); + const lease = await acquire(root, signal); + const directory = join(root, 'workspace'); + try { + signal.throwIfAborted(); + await rm(directory, { recursive: true, force: true }); + await mkdir(directory, { mode: 0o700 }); + const state = await lstat(directory); + if (!state.isDirectory() || state.isSymbolicLink()) + throw new Error('Plugin scratch cwd is not a directory'); + signal.throwIfAborted(); + return await use(await realpath(directory)); + } finally { + try { + await rm(directory, { recursive: true, force: true }); + } finally { + await lease.close(); + } + } +} + +async function acquire(root: string, signal: AbortSignal) { + while (true) { + signal.throwIfAborted(); + const lease = await tryAcquireFileLifetimeOwner(join(root, 'owner.lease')); + if (lease) return lease; + await delay(25, undefined, { signal }); + } +} diff --git a/packages/runtime-host/src/server/session-catalog-coordinator.ts b/packages/runtime-host/src/server/session-catalog-coordinator.ts index caa28481435..f93c277e99f 100644 --- a/packages/runtime-host/src/server/session-catalog-coordinator.ts +++ b/packages/runtime-host/src/server/session-catalog-coordinator.ts @@ -207,10 +207,11 @@ export interface HostSessionCatalogCoordinatorOptions { readonly continuity: SessionContinuity; readonly workspaceResolver: HostWorkspaceResolver; readonly requestDrain: () => void; + readonly isTurnBusy?: (sessionId: string) => boolean; readonly configureExecutor?: ( header: SessionHeader, config: import('@maka/core/executor-catalog').ExecutorConfiguration, - ) => Promise; + ) => Promise; readonly retireExecutor?: (sessionId: string) => Promise; readonly assertExecutorAvailable?: ( sessionId: string, @@ -329,6 +330,7 @@ export class HostSessionCatalogCoordinator { readonly #turnIndex: SessionTurnIndexReader; readonly #runtimePolicy: SessionRuntimePolicyStores; readonly #manager: SessionConfigurationAuthority; + readonly #isTurnBusy?: (sessionId: string) => boolean; readonly #admission: SessionAdmissionGate; readonly #continuity: SessionContinuity; readonly #workspaceResolver: HostWorkspaceResolver; @@ -345,6 +347,7 @@ export class HostSessionCatalogCoordinator { this.#turnIndex = options.turnIndex; this.#runtimePolicy = options.runtimePolicy; this.#manager = options.manager; + this.#isTurnBusy = options.isTurnBusy; this.#admission = options.admission; this.#continuity = options.continuity; this.#workspaceResolver = options.workspaceResolver; @@ -828,7 +831,7 @@ export class HostSessionCatalogCoordinator { ); } - const configuration = await this.#mergeConfigurationPatch(current.header, input.patch); + let configuration = await this.#mergeConfigurationPatch(current.header, input.patch); const clearsConnectionBlock = input.patch.modelTarget !== undefined && current.header.blockedReason === 'NO_REAL_CONNECTION'; @@ -850,6 +853,8 @@ export class HostSessionCatalogCoordinator { if (input.patch.executorConfig) { if ( this.#manager.runningTurnIds(input.sessionId).length || + this.#isTurnBusy?.(input.sessionId) || + current.header.status === 'running' || current.header.status === 'waiting_for_user' ) throw new SessionOperationFailure( @@ -862,7 +867,31 @@ export class HostSessionCatalogCoordinator { 'Executor configuration is unavailable', ); try { - await this.#configureExecutor(current.header, input.patch.executorConfig); + const confirmed = await this.#configureExecutor( + current.header, + // A model-only edit lets the Agent confirm mode side effects. + // Do not turn the previous mode into an explicit requirement. + input.patch.executorConfig.model && input.patch.executorConfig.mode === undefined + ? { model: input.patch.executorConfig.model } + : configuration.executorConfig!, + ); + if (confirmed) { + if ( + (input.patch.executorConfig.model && + confirmed.model !== input.patch.executorConfig.model) || + (input.patch.executorConfig.mode && + confirmed.mode !== input.patch.executorConfig.mode) + ) + throw new Error('Executor confirmation differs from the requested configuration'); + configuration = { + ...configuration, + // A provider confirmation is the complete current snapshot. In + // particular, omission clears an option the provider removed as + // a side effect of changing another option. + executorConfig: confirmed, + model: confirmed.model ?? current.header.executorId ?? configuration.model, + }; + } confirmedExecutor = current.header; } catch { throw new SessionOperationFailure( @@ -916,7 +945,7 @@ export class HostSessionCatalogCoordinator { const actual = (await this.#stores.readHeaderRecordSnapshot(previous.id)).header; if ( actual.executorId !== previous.executorId || - !actual.executorConfig?.model || + !actual.executorConfig || !this.#configureExecutor ) throw new Error('Confirmed executor model is unavailable'); @@ -1443,7 +1472,9 @@ export class HostSessionCatalogCoordinator { return { backend: patch.modelTarget || current.backend === 'ai-sdk' ? 'ai-sdk' : 'plugin-executor', executorId: patch.modelTarget ? undefined : current.executorId, - executorConfig: patch.executorConfig ?? current.executorConfig, + executorConfig: patch.executorConfig + ? { ...current.executorConfig, ...patch.executorConfig } + : current.executorConfig, llmConnectionId: model.connectionId, llmConnectionSlug: model.connectionSlug, model: patch.executorConfig?.model ?? model.model, @@ -1473,7 +1504,9 @@ export class HostSessionCatalogCoordinator { executorConfig = await this.#assertExecutorAvailable?.( input.sessionId, input.executorId, - requestedModel ? { model: requestedModel } : input.executorConfig, + requestedModel + ? { ...input.executorConfig, model: requestedModel } + : input.executorConfig, cwd, ); } catch { @@ -1524,6 +1557,7 @@ function sessionConfigurationMatches( header.backend === configuration.backend && header.executorId === configuration.executorId && header.executorConfig?.model === configuration.executorConfig?.model && + header.executorConfig?.mode === configuration.executorConfig?.mode && header.llmConnectionId === configuration.llmConnectionId && header.llmConnectionSlug === configuration.llmConnectionSlug && header.model === configuration.model && @@ -1614,7 +1648,12 @@ function createRequestFingerprint( prepared.name, prepared.labels, input.executorId - ? ['executor', input.executorId, input.executorConfig?.model ?? input.executorModel ?? null] + ? [ + 'executor', + input.executorId, + input.executorConfig?.model ?? input.executorModel ?? null, + input.executorConfig?.mode ?? null, + ] : input.modelTarget?.kind === 'default' ? ['default'] : [ diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 7305e75d6b3..0367cf3a1df 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -6,6 +6,7 @@ "type": "module", "private": true, "exports": { + "./admission-limiter": "./dist/admission-limiter.js", "./ask-user-question-tool": "./dist/ask-user-question-tool.js", "./ai-sdk-backend": "./dist/ai-sdk-backend.js", "./mcp-tools": "./dist/mcp-tools.js", diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts index aa37df2d481..d2e02cd9cd6 100644 --- a/packages/runtime/src/__tests__/plugin-executor-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-service.test.ts @@ -23,6 +23,58 @@ import { Context } from '../plugin-kernel.js'; import { PluginExecutorService } from '../plugin-executor-service.js'; import { MakaPluginTransactionBuffer } from '../plugin-runtime.js'; +test('catalog discovery forwards only an explicitly requested refresh to the provider', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const refreshes: Array = []; + plugin(root, 'profile', 'provider', 1).executors.register({ + id: 'remote', + execute: async () => ({ status: 'completed', text: '' }), + discover: async (input) => { + refreshes.push(input.refresh); + return { + id: 'remote', + displayName: 'Remote', + readiness: 'ready', + models: [], + supportsAttachments: false, + supportsModelChange: false, + }; + }, + }); + try { + await service.catalog({ cwd: '/workspace' }); + await service.catalog({ cwd: '/workspace', refresh: false }); + await service.catalog({ cwd: '/workspace', refresh: true }); + assert.deepEqual(refreshes, [undefined, undefined, true]); + } finally { + await root.fiber.dispose(); + } +}); + +for (const mode of ['auto', undefined]) { + test(`configuration returns the complete provider snapshot with mode ${mode ?? '(removed)'}`, async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const confirmed = { model: 'after', ...(mode ? { mode } : {}) }; + plugin(root, 'profile', 'provider', 1).executors.register({ + id: 'remote', + execute: async () => ({ status: 'completed', text: '' }), + configureConversation: async () => confirmed, + }); + try { + const result = await service.configureConversation('session-a', 'remote', { + conversationKey: 'session-a', + cwd: '/workspace', + configuration: { model: 'after', mode: 'ask' }, + }); + assert.deepEqual(result, confirmed); + } finally { + await root.fiber.dispose(); + } + }); +} + test('executors are scoped and pass black-box output without an Agent invocation', async () => { const root = new Context(); const service = new PluginExecutorService(root); @@ -337,6 +389,7 @@ test('catalog inspection stays process-free and model configuration is isolated let discoveries = 0, inspections = 0, configured = 0, + invalidations = 0, retired = ''; let release!: () => void; const catalog = { @@ -353,6 +406,9 @@ test('catalog inspection stays process-free and model configuration is isolated discoveries++; return catalog; }, + invalidateCatalog: () => { + invalidations++; + }, inspectConversation: async () => { inspections++; return { ...catalog, readiness: 'history_only' }; @@ -379,6 +435,8 @@ test('catalog inspection stays process-free and model configuration is isolated assert.equal(state?.readiness, 'history_only'); assert.equal(discoveries, 1); assert.equal(inspections, 1); + service.invalidateCatalog(); + assert.equal(invalidations, 1); const execution = service.execute('remote', request('session-a')); await service.configureConversation('session-b', 'remote', { conversationKey: 'session-b', diff --git a/packages/runtime/src/admission-limiter.ts b/packages/runtime/src/admission-limiter.ts index 120c68106a5..9ea28dbfe37 100644 --- a/packages/runtime/src/admission-limiter.ts +++ b/packages/runtime/src/admission-limiter.ts @@ -31,12 +31,13 @@ interface AdmissionWaiter { /** * Abort-aware FIFO permits, bounded by capacity. * - * Two boundaries use it, at different lifetimes. Child-agent runs take one + * Boundaries use it at different lifetimes. Child-agent runs take one * instance per turn: tool-call admission and child-run capacity are separate * boundaries, since one admitted tool may eventually spawn multiple children, * so the limiter belongs at the narrow spawn capability where every caller * shares the same real-run budget. Code Mode cells take one per backend, which - * has to outlive a turn — see `executeCodeModeCell`. + * has to outlive a turn — see `executeCodeModeCell`. ACP catalog probes share + * one per runtime Entry and hold permits through process cleanup. * * A caller that must turn work away rather than queue it reads `waitingCount` * before calling `acquire`. Nothing awaits between that read and the enqueue diff --git a/packages/runtime/src/plugin-data-services.ts b/packages/runtime/src/plugin-data-services.ts index 8ba4e3c6785..985bbba713b 100644 --- a/packages/runtime/src/plugin-data-services.ts +++ b/packages/runtime/src/plugin-data-services.ts @@ -65,6 +65,13 @@ export interface PluginDataRuntime { domain: 'settings' | 'storage', listener: (keys: readonly string[]) => void, ): () => void; + /** Stable, namespaced scratch cwd, leased through callback completion and cleanup. */ + withScratchDirectory?( + namespace: PluginDataNamespace, + key: string, + signal: AbortSignal, + use: (directory: string) => Promise, + ): Promise; hasCredential(namespace: PluginDataNamespace, slot: string): Promise; useCredential( namespace: PluginDataNamespace, @@ -235,6 +242,17 @@ export class PluginStorageService extends PluginNamespacedDataService { const result = await this.mutateValues([{ key, value, ...options }]); return result[key] as PluginDataSnapshot; } + /** The callback must drain any process using the directory before settling. */ + withScratchDirectory( + key: string, + signal: AbortSignal, + use: (directory: string) => Promise, + ): Promise { + const runtime = this.runtime(); + if (!runtime.withScratchDirectory) + throw new Error('Plugin scratch directories are unavailable'); + return runtime.withScratchDirectory(this.namespace(), validKey(key), signal, use); + } async delete( key: string, options: { readonly expectedRevision?: number } = {}, diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts index a8636d3658b..95c0124d802 100644 --- a/packages/runtime/src/plugin-executor-service.ts +++ b/packages/runtime/src/plugin-executor-service.ts @@ -157,6 +157,7 @@ export interface PluginExecutorContext { export interface PluginExecutorDiscoveryInput { readonly cwd: string; readonly signal: AbortSignal; + readonly refresh?: boolean; } export interface PluginExecutorConversationInput { readonly conversationKey: string; @@ -170,10 +171,13 @@ export interface PluginExecutorProvider { readonly capabilities?: PluginExecutorCapabilities; disposeConversation?(conversationKey: string): Promise; discover?(input: PluginExecutorDiscoveryInput): Promise; + /** Clear provider-owned discovery data after an observable account or setup change. */ + invalidateCatalog?(): void; + /** Return the complete confirmed configuration snapshot; omit the result when unchanged. */ configureConversation?( input: PluginExecutorConversationInput, signal: AbortSignal, - ): Promise; + ): Promise; inspectConversation?(input: PluginExecutorConversationInput): Promise; /** Confirm terminal consumption for any result; the provider decides whether it can checkpoint. */ acknowledgeExecution?(conversationKey: string, turnId: string): Promise; @@ -276,6 +280,9 @@ export class PluginExecutorService extends Service { ? { configureConversation: provider.configureConversation.bind(provider) } : {}), ...(provider.discover ? { discover: provider.discover.bind(provider) } : {}), + ...(provider.invalidateCatalog + ? { invalidateCatalog: provider.invalidateCatalog.bind(provider) } + : {}), ...(provider.inspectConversation ? { inspectConversation: provider.inspectConversation.bind(provider) } : {}), @@ -342,6 +349,7 @@ export class PluginExecutorService extends Service { async catalog( input: { cwd: string; + refresh?: boolean; sessionId?: string; /** Discover within this Session's scope without inspecting a retained conversation. */ discoverySessionId?: string; @@ -387,7 +395,11 @@ export class PluginExecutorService extends Service { cwd: input.cwd, ...(input.configuration ? { configuration: input.configuration } : {}), }) - : await entry.provider.discover?.({ cwd: input.cwd, signal: combined }); + : await entry.provider.discover?.({ + cwd: input.cwd, + signal: combined, + ...(input.refresh ? { refresh: true } : {}), + }); combined.throwIfAborted(); if (!result) return fallback; return normalizeCatalogEntry(result, entry.provider.id); @@ -399,11 +411,17 @@ export class PluginExecutorService extends Service { ); } + invalidateCatalog(): void { + for (const entry of this.registry.entries('profile')) { + if (!entry.retired) entry.provider.invalidateCatalog?.(); + } + } + async configureConversation( sessionId: string, executorId: string, input: PluginExecutorConversationInput, - ): Promise { + ): Promise { const entry = this.entry(sessionId, executorId); const configure = entry.provider.configureConversation; if ( @@ -413,11 +431,17 @@ export class PluginExecutorService extends Service { throw new Error('Executor configuration is unavailable or busy'); if (!isExecutorConfiguration(input.configuration)) throw new TypeError('Invalid executor configuration'); - await this.withActiveOperation( + return await this.withActiveOperation( entry, { conversationKey: input.conversationKey }, async (signal) => { - await configure(input, AbortSignal.any([signal, AbortSignal.timeout(30_000)])); + const confirmed = await configure( + input, + AbortSignal.any([signal, AbortSignal.timeout(30_000)]), + ); + if (confirmed !== undefined && !isExecutorConfiguration(confirmed)) + throw new TypeError('Invalid confirmed executor configuration'); + return confirmed; }, ); } diff --git a/packages/runtime/src/session-manager.ts b/packages/runtime/src/session-manager.ts index 7abda673ac3..bd416d90cf1 100644 --- a/packages/runtime/src/session-manager.ts +++ b/packages/runtime/src/session-manager.ts @@ -5362,6 +5362,7 @@ function sessionConfigurationMatchesExceptPermissionMode( header.backend === configuration.backend && header.executorId === configuration.executorId && header.executorConfig?.model === configuration.executorConfig?.model && + header.executorConfig?.mode === configuration.executorConfig?.mode && header.llmConnectionId === configuration.llmConnectionId && header.llmConnectionSlug === configuration.llmConnectionSlug && header.connectionLocked === configuration.connectionLocked && diff --git a/packages/ui/src/__tests__/executor-model-picker.test.tsx b/packages/ui/src/__tests__/executor-model-picker.test.tsx index c143f8cd23f..6d033d0a20d 100644 --- a/packages/ui/src/__tests__/executor-model-picker.test.tsx +++ b/packages/ui/src/__tests__/executor-model-picker.test.tsx @@ -19,17 +19,18 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { act, useState } from 'react'; +import { act, createRef, useState } from 'react'; import type { ChatModelChoice } from '@maka/core/chat-model-choice'; import { ExecutorModelPicker, ExecutorThinkingLevelSelector, + ExecutorModeSelector, type ExecutorSelection, type ExecutorModelPickerProps, } from '../executor-model-picker.js'; import { highestExecutorModelVariant } from '../executor-model-presentation.js'; import { NewChatModelPicker } from '../chat-model-switcher.js'; -import { Composer } from '../composer.js'; +import { Composer, type ComposerHandle } from '../composer.js'; import { exactModelChoiceValue } from '../chat-model-helpers.js'; import { LocaleProvider } from '../locale-context.js'; import { installTranscriptDom } from './transcript-test-dom.js'; @@ -76,6 +77,246 @@ const choices: ChatModelChoice[] = [ }, ]; +test('provider mode selector preserves the model and commits only a real mode ID', async () => { + const dom = installTranscriptDom(); + const selections: ExecutorSelection[] = []; + try { + await dom.render( { if (selection) selections.push(selection); }} + onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + const trigger = dom.document.querySelector('.maka-executor-mode-selector'); + assert.ok(trigger?.textContent?.includes('模式: 询问')); + await act(async () => { trigger!.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + const auto = [...dom.document.querySelectorAll('[role="option"]')].find(row => row.textContent?.includes('自动')); + assert.ok(auto); + await act(async () => { auto.dispatchEvent(new dom.window.Event('click', { bubbles: true })); }); + assert.deepEqual(selections, [{ executorId: 'antigravity', configuration: { model: 'model-0', mode: 'auto' } }]); + } finally { await dom.cleanup(); } +}); + +for (const option of ['model', 'mode'] as const) { + for (const outcome of ['confirmed', 'rejected'] as const) { + test(`Composer preserves the draft while an executor ${option} change is ${outcome}`, async () => { + const dom = installTranscriptDom(); + dom.window.getSelection = () => null; + dom.document.getSelection = () => null; + const composer = createRef(); + const sent: ExecutorSelection[] = []; + const requested: ExecutorSelection[] = []; + let finish!: () => void; + const confirmation = new Promise((resolve) => { finish = resolve; }); + const initial: ExecutorSelection = { + executorId: 'antigravity', + configuration: { model: 'model-0', mode: 'ask' }, + }; + const entry = { + ...catalog[0]!, + modes: [{ id: 'ask', name: 'Ask' }, { id: 'auto', name: 'Auto' }], + currentMode: 'ask', + supportsModeChange: true, + }; + function Harness() { + const [selection, setSelection] = useState(initial); + return { + assert.ok(next); + requested.push(next); + await confirmation; + if (outcome === 'rejected') throw new Error('Agent rejected configuration'); + setSelection(previous => ({ + ...previous, + configuration: { ...previous.configuration, ...next.configuration }, + })); + }, + onSetup: () => {}, onRetry: () => {}, onNewTask: () => {}, + }} + onSend={() => { sent.push(selection); return true; }} + onStop={() => {}} + />; + } + const click = async (element: Element | null | undefined) => { + assert.ok(element); + await act(async () => { + element.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + }; + const submit = async () => { + await act(async () => { + dom.document.querySelector('form')!.dispatchEvent( + new dom.window.Event('submit', { bubbles: true, cancelable: true }), + ); + }); + }; + try { + await dom.render(); + await act(async () => { composer.current!.setText('Keep this draft'); }); + await click(dom.document.querySelector(option === 'mode' + ? '.maka-executor-mode-selector' : '.maka-executor-selector')); + await click([...dom.document.querySelectorAll('[role="option"]')].find(row => + option === 'mode' ? row.textContent === 'Auto' : row.textContent?.includes('Agent model 1'), + )); + assert.equal(requested.length, 1); + assert.deepEqual(requested[0]!.configuration, option === 'mode' + ? { mode: 'auto' } : { model: 'model-1' }); + if (option === 'mode') { + const modelTrigger = dom.document.querySelector('.maka-executor-selector'); + assert.ok(modelTrigger?.hasAttribute('disabled') || modelTrigger?.getAttribute('aria-disabled') === 'true', + 'model changes must wait for mode confirmation'); + } + await submit(); + assert.equal(sent.length, 0, 'unconfirmed configuration must block submission'); + assert.equal(composer.current!.getText(), 'Keep this draft'); + await act(async () => { finish(); await confirmation; }); + assert.equal(sent.length, 0, 'settlement must not automatically send the draft'); + assert.equal(composer.current!.getText(), 'Keep this draft'); + if (outcome === 'rejected') assert.ok(dom.document.querySelector('[role="alert"]')); + await submit(); + assert.deepEqual(sent, [{ + ...initial, + configuration: outcome === 'confirmed' + ? { ...initial.configuration, ...requested[0]!.configuration } + : initial.configuration, + }]); + assert.equal(composer.current!.getText(), ''); + } finally { + finish(); + await dom.cleanup(); + } + }); + } +} + +test('unmounting a pending mode selector releases the Composer configuration gate', async () => { + const dom = installTranscriptDom(); + const pending: boolean[] = []; + let finish!: () => void; + const confirmation = new Promise((resolve) => { finish = resolve; }); + const onPendingChange = (value: boolean) => { pending.push(value); }; + try { + await dom.render( confirmation} + onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + await act(async () => { + dom.document.querySelector('.maka-executor-mode-selector')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + await act(async () => { + [...dom.document.querySelectorAll('[role="option"]')].find(row => row.textContent === 'Auto')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + assert.equal(pending.at(-1), true); + await dom.render(Another conversation); + assert.equal(pending.at(-1), false); + await act(async () => { finish(); await confirmation; }); + assert.equal(pending.at(-1), false, 'a late response must not lock the next conversation'); + } finally { + finish(); + await dom.cleanup(); + } +}); + +for (const fixed of [false, true]) test(`${fixed ? 'fixed' : 'draft'} executor model changes drop the previous model's mode`, async () => { + const dom = installTranscriptDom(); + const selections: ExecutorSelection[] = []; + const entry = { + ...catalog[0]!, + modes: [ + { id: 'ask', name: 'Ask' }, + { id: 'auto', name: 'Auto' }, + ], + currentMode: 'ask', + supportsModeChange: true, + }; + const selection = { + executorId: entry.id, + configuration: { model: 'model-0', mode: 'ask' }, + }; + try { + await dom.render( { if (next) selections.push(next); }} + onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + await act(async () => { + dom.document.querySelector('.maka-executor-selector')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + const model = [...dom.document.querySelectorAll('[role="option"]')] + .find((row) => row.textContent?.includes('Agent model 1')); + assert.ok(model); + await act(async () => { + model.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + + await dom.render( { if (next) selections.push(next); }} + onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + await act(async () => { + dom.document.querySelector('.maka-executor-mode-selector')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + const mode = [...dom.document.querySelectorAll('[role="option"]')] + .find((row) => row.textContent?.includes('Auto')); + assert.ok(mode); + await act(async () => { + mode.dispatchEvent(new dom.window.Event('click', { bubbles: true })); + }); + assert.deepEqual(selections, [ + { executorId: entry.id, configuration: { model: 'model-1' } }, + { executorId: entry.id, configuration: { mode: 'auto' } }, + ]); + } finally { + await dom.cleanup(); + } +}); + +for (const readiness of ['ready', 'authentication_required', 'unavailable'] as const) { + test(`the ${readiness} catalog can be refreshed from the picker`, async () => { + const dom = installTranscriptDom(); + let retries = 0; + const render = (loading = false) => dom.render( assert.fail('refresh must not change the selection')} + onSetup={() => {}} onRetry={() => { retries++; }} onNewTask={() => {}} + />); + const retry = () => [...dom.document.querySelectorAll('.maka-executor-picker-rail button')] + .find(button => button.textContent === 'Retry'); + try { + await render(); + await act(async () => dom.document.querySelector('.maka-executor-selector')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true }))); + assert.ok(retry()); + await act(async () => retry()!.dispatchEvent(new dom.window.Event('click', { bubbles: true }))); + assert.equal(retries, 1); + await render(true); + assert.ok(retry()?.hasAttribute('disabled') || retry()?.getAttribute('aria-disabled') === 'true'); + } finally { await dom.cleanup(); } + }); +} + test('the picker shows a generic loading state until the executor catalog arrives', async () => { const dom = installTranscriptDom(); const selections: unknown[] = []; @@ -119,6 +360,43 @@ test('the picker shows a generic loading state until the executor catalog arrive } }); +test('pending Session inspection is not reported as a failed model change', async () => { + const dom = installTranscriptDom(); + const render = (loading: boolean) => dom.render( + {}} onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />, + ); + try { + await render(true); + assert.equal(dom.document.querySelector('.maka-executor-notice'), null); + await render(false); + assert.match(dom.document.querySelector('.maka-executor-notice')?.textContent ?? '', /当前不可用/u); + assert.doesNotMatch(dom.document.body.textContent ?? '', /模型切换失败/u); + } finally { await dom.cleanup(); } +}); + +test('first-send admission keeps the complete model, thinking and mode controls visible', async () => { + const dom = installTranscriptDom(); + const known = { ...groupedCatalog[0]!, modes: [{ id: 'default', name: 'Default' }], currentMode: 'default', supportsModeChange: true }; + try { + for (const phase of ['local-pending', 'initializing', 'running', 'settled']) { + await dom.render( {}} onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + assert.match(dom.document.querySelector('.maka-executor-selector')?.textContent ?? '', /Gemini 3\.8 Flash/u, phase); + assert.match(dom.document.querySelector('.maka-thinking-level-selector')?.textContent ?? '', /高/u, phase); + assert.ok([...dom.document.querySelectorAll('button')].some(button => button.textContent?.includes('模式: Default')), phase); + assert.equal(dom.document.querySelector('.maka-executor-notice'), null, phase); + } + } finally { await dom.cleanup(); } +}); + test('failed Antigravity discovery closes the picker before opening external-agent settings', async () => { const dom = installTranscriptDom(); let setupOpened = false; @@ -619,6 +897,25 @@ for (const initial of [undefined, 'flash-high', 'flash-mid']) test(`grouped exte } finally { await dom.cleanup(); } }); +test('changing draft thinking levels drops a mode tied to the previous exact model', async () => { + const dom = installTranscriptDom(); + const selections: ExecutorSelection[] = []; + try { + await dom.render( { if (next) selections.push(next); }} + onSetup={() => {}} onRetry={() => {}} onNewTask={() => {}} + />); + await act(async () => dom.document.querySelector('[aria-haspopup="listbox"]')! + .dispatchEvent(new dom.window.Event('click', { bubbles: true }))); + const low = [...dom.document.querySelectorAll('[role="option"]')].find(row => row.textContent === 'Low'); + assert.ok(low); + await act(async () => low.dispatchEvent(new dom.window.Event('click', { bubbles: true }))); + assert.deepEqual(selections, [{ executorId: 'antigravity', configuration: { model: 'flash-low' } }]); + } finally { await dom.cleanup(); } +}); + for (const lock of ['disabled', 'readOnly', 'fixed', 'lost'] as const) test(`external thinking respects ${lock} locks`, async () => { const dom = installTranscriptDom(); let calls = 0; diff --git a/packages/ui/src/executor-model-picker.tsx b/packages/ui/src/executor-model-picker.tsx index 7b66869718e..734dca7c7fd 100644 --- a/packages/ui/src/executor-model-picker.tsx +++ b/packages/ui/src/executor-model-picker.tsx @@ -60,6 +60,7 @@ interface ExecutorCopy { title: string; nativeOperations: string; search: string; + searchModes: string; manage: string; default: string; loading: string; @@ -76,6 +77,9 @@ interface ExecutorCopy { retry: string; newTask: string; selectionFailed: string; + invalid: string; + mode: string; + modeFailed: string; } const EXECUTOR_COPY = { @@ -83,6 +87,7 @@ const EXECUTOR_COPY = { title: 'Executor', nativeOperations: 'This operation requires Maka. Start a new Maka task.', search: 'Search models', + searchModes: 'Search modes', manage: 'Manage external agents', default: 'Agent default', loading: 'Loading agents and models…', @@ -101,11 +106,15 @@ const EXECUTOR_COPY = { retry: 'Retry', newTask: 'New task', selectionFailed: 'Model change failed. Try again.', + invalid: 'The selected Agent configuration is no longer available. Choose again.', + mode: 'Mode', + modeFailed: 'Mode change failed. Try again.', }, 'zh-CN': { title: '执行者', nativeOperations: '此操作仅支持 Maka。请新建 Maka 任务。', search: '搜索模型', + searchModes: '搜索模式', manage: '管理外部 Agent', default: 'Agent 默认', loading: '正在读取执行者与模型…', @@ -122,11 +131,15 @@ const EXECUTOR_COPY = { retry: '重试', newTask: '新建任务', selectionFailed: '模型切换失败,请重试。', + invalid: '所选 Agent 配置已失效,请重新选择。', + mode: '模式', + modeFailed: '模式切换失败,请重试。', }, 'zh-TW': { title: '執行者', nativeOperations: '此操作僅支援 Maka。請建立 Maka 任務。', search: '搜尋模型', + searchModes: '搜尋模式', manage: '管理外部 Agent', default: 'Agent 預設', loading: '正在讀取執行者與模型…', @@ -143,6 +156,9 @@ const EXECUTOR_COPY = { retry: '重試', newTask: '建立新任務', selectionFailed: '模型切換失敗,請重試。', + invalid: '所選 Agent 設定已失效,請重新選擇。', + mode: '模式', + modeFailed: '模式切換失敗,請重試。', }, } satisfies UiCatalog; @@ -159,6 +175,8 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { const selected = props.catalog.find((entry) => entry.id === props.selection?.executorId); const [open, setOpen] = useState(false); const [selecting, setSelecting] = useState(false); + const [modePending, setModePending] = useState(false); + const configurationPending = selecting || modePending; const [selectionFailed, setSelectionFailed] = useState(false); const [browsedId, setBrowsedId] = useState(props.selection?.executorId ?? NATIVE); useEffect(() => { @@ -167,14 +185,18 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { setSelectionFailed(false); } }, [open, props.selection?.executorId]); - useEffect(() => { props.onPendingChange?.(selecting); }, [selecting, props.onPendingChange]); + useEffect(() => { props.onPendingChange?.(configurationPending); }, [configurationPending, props.onPendingChange]); useEffect(() => () => props.onPendingChange?.(false), [props.onPendingChange]); const browsed = props.catalog.find((entry) => entry.id === browsedId); const currentModel = browsedId === props.selection?.executorId ? (props.selection?.configuration.model ?? browsed?.currentModel) : browsed?.currentModel; - const selectedUnavailable = !!props.selection && selected?.readiness !== 'ready'; + const selectedUnavailable = !!props.selection && (selected ? selected.readiness !== 'ready' : !props.loading); + const selectedInvalid = !props.loading && !!props.selection && !!selected && selected.readiness === 'ready' && ( + (!!props.selection.configuration.model && !selected.models.some(model => model.id === props.selection!.configuration.model)) || + (!!props.selection.configuration.mode && !selected.modes?.some(mode => mode.id === props.selection!.configuration.mode)) + ); const selectedModel = props.selection?.configuration.model ?? selected?.currentModel; const selectedGroup = selected && executorModelGroup(selected, selectedModel); const currentGroup = browsed && executorModelGroup(browsed, currentModel); @@ -184,7 +206,7 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { }` : (props.nativeLabel ?? 'Maka'); const chooseModel = async (configuration: ExecutorConfiguration, entry = browsed) => { - if (!entry || entry.readiness !== 'ready' || props.disabled || props.isReadOnly || selecting || + if (!entry || entry.readiness !== 'ready' || props.disabled || props.isReadOnly || configurationPending || (props.fixed && !entry.supportsModelChange)) return; setSelecting(true); setSelectionFailed(false); @@ -211,7 +233,7 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { props.onNewTask(); }; const restore = () => { - if (props.onRestore && !selecting) { + if (props.onRestore && !configurationPending) { setSelecting(true); void Promise.resolve(props.onRestore()).catch(() => setSelectionFailed(true)).finally(() => setSelecting(false)); } @@ -225,7 +247,7 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { width="min(620px, 92vw)" isOpen={open} onOpenChange={setOpen} - isEnabled={!props.disabled && !props.isReadOnly} + isEnabled={!props.disabled && !props.isReadOnly && !modePending} content={

{browsedId === NATIVE ? ( @@ -281,7 +313,7 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { { const group = executorModelGroup(browsed, model.id); if (group && browsed.models.find(candidate => group.variants.some(variant => variant.modelId === candidate.id))?.id !== model.id) return []; @@ -298,7 +330,12 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { const model = group ? highestExecutorModelVariant(group) : value.slice('model:'.length); - if (model !== undefined) return chooseModel({ model }); + if (model !== undefined) return chooseModel({ + model, + ...(!props.fixed && model === currentModel && props.selection?.executorId === browsed.id && props.selection.configuration.mode + ? { mode: props.selection.configuration.mode } + : {}), + }); }} /> @@ -312,7 +349,7 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) {

{browsed ? copy[browsed.readiness] : copy.unavailable}

{browsed?.readiness === 'restorable' || browsed?.readiness === 'restore_failed' ? ( - - {props.selection ? : props.nativeThinkingControl} - {(selectedUnavailable || props.error) && ( + {props.selection ? : props.nativeThinkingControl} + + {(selectedUnavailable || selectedInvalid || props.error) && ( - {selected && selected.readiness !== 'ready' ? copy[selected.readiness] : copy.unavailable} + {selectedUnavailable ? (selected && selected.readiness !== 'ready' ? copy[selected.readiness] : copy.unavailable) : selectedInvalid ? copy.invalid : copy.selectionFailed} {selected?.readiness === 'restorable' || selected?.readiness === 'restore_failed' ? ( -