Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
5425513
feat(acp): add mode selection and scoped catalog lifecycle
Sun-GLiang Sep 29, 2026
c925d26
fix(acp): preserve executor configuration authority
Sun-GLiang Sep 29, 2026
cdc7686
fix(protocol): validate optional executor catalog fields
Sun-GLiang Sep 29, 2026
84660f1
fix(acp): reconcile reviewed configuration races
Sun-GLiang Sep 29, 2026
086cbb0
fix(acp): preserve confirmed selection across agent drift
Sun-GLiang Sep 29, 2026
8edbcbb
fix(acp): keep saved selection across agent drift
Sun-GLiang Sep 29, 2026
c2edb3f
docs(acp): reconcile official agent acceptance findings
Sun-GLiang Sep 29, 2026
a85b381
fix(desktop): select extracted ACP directory on macOS
Sun-GLiang Sep 29, 2026
17db8d7
Merge apache/main into ACP mode catalog
Sun-GLiang Sep 30, 2026
054b051
Merge latest apache/main and advance ACP protocol epoch
Sun-GLiang Sep 30, 2026
3643698
fix(acp): validate mode after model selection
Sun-GLiang Sep 30, 2026
88a36a2
docs(acp): record real directory refresh isolation
Sun-GLiang Sep 30, 2026
812ef47
docs(acp): record second project desktop catalog
Sun-GLiang Sep 30, 2026
82d0d33
docs(acp): reconcile second project acceptance summary
Sun-GLiang Sep 30, 2026
da5aa4e
docs(acp): record desktop cross-project acceptance
Sun-GLiang Sep 30, 2026
00464c1
docs(acp): complete repeatable PR 5826 acceptance procedure
Sun-GLiang Oct 1, 2026
b051b17
docs(acp): record inconclusive eligibility recheck
Sun-GLiang Oct 1, 2026
ce46c48
fix(acp): advance mode catalog protocol epoch beyond main
Sun-GLiang Oct 1, 2026
487ae0d
Merge main and reconcile ACP mode catalog epoch
Sun-GLiang Oct 1, 2026
a023bba
docs(acp): record successful official execution recheck
Sun-GLiang Oct 1, 2026
0c4dbac
docs: close official Agent Desktop acceptance
Sun-GLiang Oct 1, 2026
3263e48
fix(acp): restore model before dependent mode on rollback
Sun-GLiang Oct 1, 2026
6c4cd2d
fix(ui): block sends until executor mode is confirmed
Sun-GLiang Oct 1, 2026
cc9c012
fix(acp): isolate draft catalog probes from project sessions
Sun-GLiang Oct 2, 2026
8ef0105
fix(acp): validate program selection and consolidate protocol history
Sun-GLiang Oct 2, 2026
82d6b12
chore(test): sync Windows program selection skip inventory
Sun-GLiang Oct 2, 2026
4fefdc1
fix(acp): reuse leased catalog paths and restart invalidated waiters
Sun-GLiang Oct 2, 2026
4c12e19
docs(acp): record stable probe and preceding desktop acceptance
Sun-GLiang Oct 2, 2026
4b7fb5e
test(acp): cover catalog refresh and mode configuration boundaries
Sun-GLiang Oct 2, 2026
91df4ac
fix(desktop): preserve executor controls during first send
Sun-GLiang Oct 3, 2026
a103d2f
fix(test): import executor composer through conversation API
Sun-GLiang Oct 3, 2026
2d016ea
Merge main and preserve executor handoff within architecture budget
Sun-GLiang Oct 3, 2026
b51fd2e
fix(acp): reject unavailable explicit modes before prompting
Sun-GLiang Oct 3, 2026
3779a65
fix(acp): recover stale modes and restore catalog refresh
Sun-GLiang Oct 3, 2026
eb37fa6
Merge main and preserve executor handoff in Composer ownership
Sun-GLiang Oct 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 63 additions & 2 deletions apps/desktop/src/main/__tests__/executor-selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ 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, 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 };

Expand Down Expand Up @@ -205,7 +205,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 () => {
Expand All @@ -219,6 +220,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('<html><body><div id="root"></div></body></html>');
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<typeof useExecutorSelection>;
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) {
Expand Down Expand Up @@ -246,3 +292,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);
});
Original file line number Diff line number Diff line change
@@ -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');
});
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ async function mount(
list?: boolean;
executable?: string;
remote?: boolean;
selectExecutable?: () => Promise<string | undefined>;
start?: (value: ExternalAgentSetupStart) => Promise<ExternalAgentSetupProjection>;
} = {},
) {
Expand Down Expand Up @@ -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' };
Expand Down Expand Up @@ -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/);
Expand Down
41 changes: 41 additions & 0 deletions apps/desktop/src/main/external-agent-executable-selection.ts
Original file line number Diff line number Diff line change
@@ -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<OpenDialogReturnValue>,
platform: NodeJS.Platform = process.platform,
): Promise<string | undefined> {
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;
}
8 changes: 4 additions & 4 deletions apps/desktop/src/main/runtime-host-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? [],
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1024,7 +1024,7 @@ export interface MakaBridge {
};

newTasks: {
getExecutors(target: DesktopNewTaskTarget, cwd: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
getExecutors(target: DesktopNewTaskTarget, cwd: string, refresh?: boolean): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
getCatalog(): Promise<DesktopNewTaskCatalog>;
subscribeChanges(handler: () => void): () => void;
addProject(host: DesktopNewTaskHostRef, name?: string): Promise<
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DesktopNewTaskCatalog> {
return loadNewTaskCatalog();
Expand Down
Loading
Loading