feat(desktop): complete Antigravity ACP plugin execution (#5224)

Let Maka Desktop run external agents through the plugin executor seam, with Antigravity ACP as the first integration (#5103 PR 2).

The ACP runtime plugin owns process lifecycle, retained sessions, permission/question bridging, cancellation, and event projection; the Antigravity adapter owns variant identity and model grouping; the Host coordinates catalog discovery and staged plugin bundles; Desktop gets a generic executor/model picker. Process loss degrades to readable history-only sessions; restoration is PR 3, modes are PR 4.

New protocol surface (plugin.executor.query, session executor fields, executorConfig) is covered by compatibility epoch 181; mismatched peers are rejected at handshake.

Generated-by: OpenAI Codex
This commit is contained in:
Sun-GLiang authored and GitHub committed 2026-09-23 17:05:59 +08:00
1 parent b004473edb
commit 0cb4fc32bd
127 files changed
+8795 -352

No files matched your search

+2 -2
View File
@@ -33,7 +33,7 @@
"build:preload": "esbuild src/preload/preload.ts --bundle --platform=node --format=cjs --outfile=dist/preload/preload.cjs --external:electron",
"build:overlay": "node ../../scripts/build-cursor-overlay.mjs",
"build:renderer": "vite build && node scripts/check-renderer-entry-output.mjs && node ../../scripts/check-third-party-notices.mjs",
"build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build",
"build:workspace-deps": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/ui run build",
"package:macos-arm64": "electron-builder --config electron-builder.config.mjs --mac dmg zip --arm64 --publish never",
"package:macos-x64": "electron-builder --config electron-builder.config.mjs --mac dmg zip --x64 --publish never",
"package:windows-x64": "electron-builder --config electron-builder.config.mjs --win nsis zip --x64 --publish never",
@@ -43,7 +43,7 @@
"package:linux-deb-arm64": "electron-builder --config electron-builder.config.mjs --linux deb --arm64 --publish never",
"typecheck": "tsc -p tsconfig.preload.json --noEmit && tsc -p tsconfig.main.json --noEmit && tsc -p tsconfig.renderer.json --noEmit && tsc -p tsconfig.storybook.json --noEmit",
"typecheck:stories": "tsc -p tsconfig.storybook.json --noEmit",
"test:dist": "node --test --test-force-exit \"dist/main/**/*.test.js\" scripts/dev-app-runtime.test.mjs scripts/vite-workspace-packages.test.mjs scripts/vite-client-plugin-slots.test.mjs",
"test:dist": "node --test --test-force-exit \"dist/main/**/*.test.js\" scripts/dev-app-runtime.test.mjs scripts/vite-workspace-packages.test.mjs scripts/vite-client-plugin-slots.test.mjs scripts/build-executor-plugins.test.mjs",
"e2e": "npm run build:with-deps && playwright test --config e2e/playwright.config.ts",
"measure": "npm run build:with-deps && playwright test --config perf/playwright.config.ts",
"build:with-deps": "npm run build:workspace-deps && npm run build",
+10 -81
View File
@@ -96,7 +96,6 @@
"src/renderer/model-catalog-choices.ts",
"src/renderer/model-connection-errors.ts",
"src/renderer/nav-selection.ts",
"src/renderer/new-task-reload-intent.ts",
"src/renderer/onboarding-hero-copy.ts",
"src/renderer/onboarding-hero.tsx",
"src/renderer/onboarding-provider-types.ts",
@@ -110,7 +109,6 @@
"src/renderer/session-collaboration-dialog.tsx",
"src/renderer/session-copy-attempt.ts",
"src/renderer/session-error-presentation.ts",
"src/renderer/session-health-notice.ts",
"src/renderer/session-read-state.ts",
"src/renderer/session-status-presentation.ts",
"src/renderer/session-workspace-actions.ts",
@@ -193,7 +191,6 @@
"src/renderer/settings/use-optimistic-settings-draft.ts",
"src/renderer/settings/web-search-settings-page.tsx",
"src/renderer/settled-message-merge.ts",
"src/renderer/shell-chat-model-selection.ts",
"src/renderer/shell-run-update-state.ts",
"src/renderer/side-chat-command.ts",
"src/renderer/skill-invocation-feedback.ts",
@@ -206,11 +203,9 @@
"src/renderer/use-app-shell-session-list.ts",
"src/renderer/use-app-shell-session-ui-reads.ts",
"src/renderer/use-app-shell-session-workspace.ts",
"src/renderer/use-new-task-choice.ts",
"src/renderer/use-onboarding-snapshot.ts",
"src/renderer/use-project-context.ts",
"src/renderer/use-shell-appearance.ts",
"src/renderer/use-shell-chat-model.ts",
"src/renderer/use-shell-connections.ts",
"src/renderer/use-shell-live-turn.ts",
"src/renderer/use-shell-memory-pill.ts",
@@ -234,7 +229,6 @@
"src/renderer/features/module-hub/ui/module-hub-host.tsx -> src/renderer/mcp-page",
"src/renderer/features/session-navigation/model/session-list-layout.ts -> src/renderer/browser-storage",
"src/renderer/features/session-navigation/model/session-rail-layout-store.ts -> src/renderer/browser-storage",
"src/renderer/features/task-entry/model/task-entry-selection.ts -> src/renderer/new-task-reload-intent",
"src/renderer/features/task-entry/ui/task-entry-host.tsx -> src/renderer/remote-project-directory-dialog",
"src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/browser-storage",
"src/renderer/features/workbar/controller/use-workbar-controller.ts -> src/renderer/side-chat-command",
@@ -326,7 +320,7 @@
"@maka/ui": 1
},
"importSpecifiers": 10,
"nonTriviaTokens": 3590
"nonTriviaTokens": 3575
},
"src/renderer/app-shell-chrome-actions.tsx": {
"importDeclarations": 1,
@@ -679,7 +673,7 @@
"nonTriviaTokens": 1243
},
"src/renderer/app-shell.tsx": {
"importDeclarations": 61,
"importDeclarations": 59,
"bridgePaths": {
"window.maka.attachments": 1,
"window.maka.attachments.readBytes": 1,
@@ -804,11 +798,9 @@
"./use-app-shell-composer-quotes": 1,
"./use-app-shell-session-ui-reads": 1,
"./use-app-shell-session-workspace": 1,
"./use-new-task-choice": 1,
"./use-onboarding-snapshot": 1,
"./use-project-context": 1,
"./use-shell-appearance": 1,
"./use-shell-chat-model": 1,
"./use-shell-connections": 1,
"./use-shell-live-turn": 1,
"./use-shell-memory-pill": 1,
@@ -827,8 +819,8 @@
"@maka/ui": 1,
"react": 1
},
"importSpecifiers": 97,
"nonTriviaTokens": 12698
"importSpecifiers": 95,
"nonTriviaTokens": 12681
},
"src/renderer/use-app-shell-composer-quotes.ts": {
"importDeclarations": 0,
@@ -891,7 +883,7 @@
"nonTriviaTokens": 135
},
"src/renderer/use-app-shell-session-workspace.ts": {
"importDeclarations": 5,
"importDeclarations": 4,
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
@@ -905,16 +897,16 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./application/contracts/new-task-reload-intent.js": 1,
"./application/contracts/session-catalog/session-catalog-state.js": 1,
"./application/contracts/session-catalog/use-external-store-selector.js": 1,
"./bootstrap-selection-lease.js": 1,
"./features/conversation/index.js": 1,
"./new-task-reload-intent.js": 1,
"./session-workspace-actions.js": 1,
"./use-app-shell-session-list.js": 1,
"react": 1
},
"importSpecifiers": 5,
"importSpecifiers": 4,
"nonTriviaTokens": 459
}
},
@@ -1058,9 +1050,9 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./application/contracts/new-task-reload-intent.js": 1,
"./application/contracts/session-inspector/latest-request-usage.js": 1,
"./composer-mentions.js": 1,
"./new-task-reload-intent.js": 1,
"@maka/ui": 1,
"react": 1
}
@@ -1792,17 +1784,6 @@
"./browser-storage.js": 1
}
},
"src/renderer/new-task-reload-intent.ts": {
"bridgePaths": {},
"environmentCapabilities": {
"sessionStorage": 2
},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
},
"src/renderer/onboarding-hero-copy.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
@@ -2016,17 +1997,6 @@
"./locales/conversation-copy.js": 1
}
},
"src/renderer/session-health-notice.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./locales/conversation-copy.js": 1
}
},
"src/renderer/session-read-state.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
@@ -2059,8 +2029,8 @@
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./application/contracts/transient-message-projection.js": 1,
"./new-task-reload-intent.js": 1
"./application/contracts/new-task-reload-intent.js": 1,
"./application/contracts/transient-message-projection.js": 1
}
},
"src/renderer/session-workspace-errors.ts": {
@@ -3735,15 +3705,6 @@
"actionFactories": [],
"dependencyPaths": {}
},
"src/renderer/shell-chat-model-selection.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {}
},
"src/renderer/shell-run-update-state.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
@@ -3844,21 +3805,6 @@
"./locales/conversation-copy.js": 1
}
},
"src/renderer/use-new-task-choice.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
"useEffect": 1,
"useState": 1
},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./new-task-reload-intent.js": 1,
"react": 1
}
},
"src/renderer/use-onboarding-snapshot.ts": {
"bridgePaths": {
"window.maka.connections.subscribeEvents": 1,
@@ -3930,23 +3876,6 @@
"react": 1
}
},
"src/renderer/use-shell-chat-model.ts": {
"bridgePaths": {},
"environmentCapabilities": {},
"hookCalls": {
"useNewTaskChoice": 2
},
"lifecycleMethods": {},
"unresolvedDependencies": 0,
"actionFactories": [],
"dependencyPaths": {
"./locales/conversation-copy.js": 1,
"./session-health-notice.js": 1,
"./shell-chat-model-selection.js": 1,
"./use-new-task-choice.js": 1,
"react": 1
}
},
"src/renderer/use-shell-connections.ts": {
"bridgePaths": {
"window.maka.connections.getSnapshot": 2,
@@ -0,0 +1,39 @@
/*
* 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 { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { build } from 'esbuild';
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url));
const packages = ['acp-executor-plugin', 'antigravity-acp-plugin'];
/** Build the exact entrypoints resolved and staged by the Host. */
export async function buildExecutorPlugins({ outputRoot = repoRoot } = {}) {
await Promise.all(packages.map((name) => build({
absWorkingDir: repoRoot,
entryPoints: [`packages/${name}/src/index.ts`],
outfile: resolve(outputRoot, `packages/${name}/dist/plugin.mjs`),
bundle: true,
platform: 'node',
format: 'esm',
target: 'node22',
logLevel: 'warning',
})));
}
@@ -0,0 +1,51 @@
/*
* 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 { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { test } from 'node:test';
import { buildExecutorPlugins } from './build-executor-plugins.mjs';
test('dev replaces stale Host plugin entries with loadable PR2 implementations', async () => {
const outputRoot = await mkdtemp(join(tmpdir(), 'maka-dev-executor-build-'));
try {
const runtime = join(outputRoot, 'packages/acp-executor-plugin/dist/plugin.mjs');
const adapter = join(outputRoot, 'packages/antigravity-acp-plugin/dist/plugin.mjs');
await mkdir(join(outputRoot, 'packages/acp-executor-plugin/dist'), { recursive: true });
await writeFile(runtime, 'export default { stale: true };');
// Also exercises a clean adapter build with no existing output directory.
await buildExecutorPlugins({ outputRoot });
const acp = await import(pathToFileURL(runtime).href);
const antigravity = await import(pathToFileURL(adapter).href);
const provider = new acp.AcpExecutor(antigravity.antigravityAcpAdapter, {
executable: join(outputRoot, 'missing-agent'),
});
assert.equal(typeof provider.discover, 'function');
assert.equal(typeof provider.inspectConversation, 'function');
assert.equal(typeof provider.configureConversation, 'function');
const catalog = await provider.discover({ cwd: outputRoot, signal: new AbortController().signal });
assert.equal(catalog.readiness, 'unavailable');
assert.deepEqual(catalog.models, []);
} finally {
await rm(outputRoot, { recursive: true, force: true });
}
});
+8
View File
@@ -42,6 +42,7 @@ import { fileURLToPath } from 'node:url';
import { createServer } from 'vite';
import { build as esbuildBuild } from 'esbuild';
import { buildCursorOverlay } from '../../../scripts/build-cursor-overlay.mjs';
import { buildExecutorPlugins } from './build-executor-plugins.mjs';
import {
createDevelopmentLaunchSession,
handleDevelopmentLaunchOutcome,
@@ -98,6 +99,13 @@ const librariesBuild = runNodeTool(REPO_ROOT, TSC_CLI, ['--build', 'tsconfig.lib
);
await Promise.all([
librariesBuild,
// Host stages the self-contained plugin.mjs entries, not tsc's index.js.
// Rebuild them after workspace dependencies so branch switches cannot load
// stale executor implementations (or fail on a clean checkout).
librariesBuild.then(() => buildExecutorPlugins()).then(
() => log('build', 'executor plugin bundles — done'),
(e) => { log('build', `executor plugin bundles — FAILED: ${e.message}`); throw e; },
),
librariesBuild.then(() => runNodeTool(REPO_ROOT, RUNTIME_WORKER_BUILD, [])).then(
() => log('build', 'filesystem worker bundle — done'),
(e) => { log('build', `filesystem worker bundle — FAILED: ${e.message}`); throw e; },
@@ -98,20 +98,22 @@ test('renderer-facing Runtime Host protocol does not load Node crypto', async (t
t.after(() => server.close());
await server.listen();
const protocolModule = join(
repoRoot,
'packages/runtime-host/dist/protocol/client-capability.js',
);
const response = await fetch(`${server.resolvedUrls.local[0]}@fs/${protocolModule}`);
const transformed = await response.text();
for (const module of ['client-capability', 'plugin-platform']) {
const protocolModule = join(repoRoot, `packages/runtime-host/dist/protocol/${module}.js`);
const response = await fetch(`${server.resolvedUrls.local[0]}@fs/${protocolModule}`);
const transformed = await response.text();
assert.equal(response.status, 200, transformed);
// A missing dist is served as the SPA fallback, and index.html trivially
// satisfies the assertion below. Say so instead of passing.
assert.doesNotMatch(
transformed,
/^<!doctype html>/iu,
'Vite served the SPA fallback; build @maka/runtime-host first',
);
assert.doesNotMatch(transformed, /vite-browser-external:node:crypto/u);
assert.equal(response.status, 200, transformed);
// A missing dist is served as the SPA fallback, and index.html trivially
// satisfies the assertions below. Say so instead of passing.
assert.doesNotMatch(
transformed,
/^<!doctype html>/iu,
'Vite served the SPA fallback; build @maka/runtime-host first',
);
assert.doesNotMatch(transformed, /vite-browser-external:node:crypto/u);
// Importing the server executor service evaluates its Node crypto import
// before React mounts, leaving the development window blank.
assert.doesNotMatch(transformed, /runtime\/dist\/plugin-executor-service\.js/u);
}
});
@@ -125,3 +125,22 @@ test('a main-side attachment rejection refuses the send instead of leaving it un
}
assert.equal(errorLog.mock.callCount(), 0, 'an expected attachment rejection logs no diagnostic');
});
test('unsupported executor attachments preserve the pending draft before any Session is created', async () => {
const restore = installWindow({ newTasks: { create: async () => assert.fail('must reject before creation') } });
const pending = [fileAttachment(10, 0)];
const errors: string[] = [];
try {
const actions = createAppShellChatActions({
...createActionsDeps(),
executorSelection: { executorId: 'external', configuration: { model: 'chosen' } },
executorEntry: { readiness: 'ready', supportsAttachments: false },
toastApi: { error: (message) => { errors.push(message); }, info() {} },
});
assert.equal(await actions.send('keep my instructions', pending), false);
assert.equal(pending.length, 1);
assert.equal(pending[0].stagingKey, 'staged-file-0');
assert.match(errors[0], /Remove unsupported attachments/);
} finally { restore(); }
});
@@ -28,7 +28,7 @@ import {
readNewTaskReloadIntent,
UNRESOLVED_NEW_TASK_DRAFT_KEY,
writeNewTaskReloadDraft,
} from '../../renderer/new-task-reload-intent.js';
} from '../../renderer/application/contracts/new-task-reload-intent.js';
type Summary = { id: string; lastMessageAt?: number; isArchived: boolean };
@@ -28,7 +28,7 @@ import {
markNewTaskReloadIntent,
UNRESOLVED_NEW_TASK_DRAFT_KEY,
writeNewTaskReloadDraft,
} from '../../renderer/new-task-reload-intent.js';
} from '../../renderer/application/contracts/new-task-reload-intent.js';
const originalGlobals = {
document: globalThis.document,
@@ -0,0 +1,198 @@
/*
* 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 test from 'node:test';
import { parseHTML } from 'linkedom';
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';
const entry: ExecutorCatalogEntry = { id: 'external', displayName: 'External', readiness: 'ready', models: [{ id: 'selected', name: 'Selected' }], supportsAttachments: false, supportsModelChange: true };
test('a catalog change during failed discovery retries after the in-flight result settles', 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')!);
let latest!: ReturnType<typeof useExecutorSelection>;
let notify!: () => void;
let finishFirst!: (value: readonly ExecutorCatalogEntry[]) => void;
const first = new Promise<readonly ExecutorCatalogEntry[]>(resolve => { finishFirst = resolve; });
let reads = 0;
const services = {
subscribeChanges: () => () => {},
newTasks: {
subscribeChanges: (handler: () => void) => { notify = handler; return () => {}; },
getExecutors: async () => (++reads === 1 ? first : [entry]),
},
sessions: {},
} as unknown as ConversationServices;
function Probe() {
latest = useExecutorSelection({ key: 'new-task', cwd: '/fixture', target: { hostId: 'host', profileId: 'profile', projectId: null } });
return null;
}
try {
await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe) })));
assert.equal(reads, 1);
notify();
await act(async () => {
finishFirst([{ ...entry, readiness: 'unavailable', models: [] }]);
await first;
});
assert.equal(reads, 2);
assert.equal(latest.entry, undefined);
assert.deepEqual(latest.catalog, [entry]);
} 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('late draft discovery cannot replace the current target; existing tasks inspect without probing', 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')!);
let latest!: ReturnType<typeof useExecutorSelection>;
let discoveryCalls = 0, inspectionCalls = 0;
let finishOld!: (value: readonly ExecutorCatalogEntry[]) => void;
const old = new Promise<readonly ExecutorCatalogEntry[]>(resolve => { finishOld = resolve; });
let ready = false;
let serverModel = 'selected';
let inspect = async (): Promise<readonly ExecutorCatalogEntry[]> => [{
...entry,
currentModel: serverModel,
readiness: ready ? 'ready' : 'history_only',
}];
let write: () => Promise<unknown> = async () => ({ ok: false, code: 'operation_unavailable' });
const services = {
subscribeChanges: () => () => {},
newTasks: { subscribeChanges: () => () => {}, getExecutors: async () => { discoveryCalls++; return discoveryCalls === 1 ? old : [entry]; } },
sessions: { getExecutorState: async () => { inspectionCalls++; return inspect(); }, setExecutorModelConfiguration: () => write() },
} as unknown as ConversationServices;
function Probe(props: { draftKey: string; session?: SessionSummary }) {
latest = useExecutorSelection({ key: props.draftKey, cwd: '/fixture', target: { hostId: 'host', profileId: 'profile', projectId: null }, session: props.session });
return null;
}
async function render(draftKey: string, session?: SessionSummary) {
await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe, { draftKey, session }) })));
}
try {
await render('old');
await render('current');
assert.deepEqual(latest.catalog, [entry]);
await act(async () => { finishOld([{ ...entry, id: 'stale' }]); await old; });
assert.deepEqual(latest.catalog, [entry]);
await act(async () => latest.select({ executorId: 'external', configuration: { model: 'selected' } }));
await act(async () => latest.refresh());
assert.equal(latest.selection?.configuration.model, 'selected');
await render('current', { id: 'saved', executorId: 'external', executorConfig: { model: 'selected' } } as SessionSummary);
assert.equal(latest.entry?.readiness, 'history_only');
assert.equal(discoveryCalls, 3);
assert.equal(inspectionCalls, 1);
let finishInspection!: (value: readonly ExecutorCatalogEntry[]) => void;
const slowInspection = new Promise<readonly ExecutorCatalogEntry[]>((resolve) => {
finishInspection = resolve;
});
inspect = () => slowInspection;
let firstRefresh!: Promise<void>;
let duplicateRefresh!: Promise<void>;
await act(async () => {
firstRefresh = latest.refresh();
duplicateRefresh = latest.refresh();
await Promise.resolve();
});
assert.equal(firstRefresh, duplicateRefresh, 'concurrent inspections share one request');
assert.equal(inspectionCalls, 2);
await act(async () => {
finishInspection([{ ...entry, currentModel: serverModel, readiness: 'history_only' }]);
await firstRefresh;
});
inspect = async () => [{
...entry,
currentModel: serverModel,
readiness: ready ? 'ready' : 'history_only',
}];
await act(async () => { await assert.rejects(latest.select({ executorId: 'external', configuration: { model: 'rejected' } })); });
assert.equal(latest.selection?.configuration.model, 'selected');
assert.equal(latest.error, 'operation_unavailable');
ready = true;
await act(async () => latest.refresh());
let confirm!: (value: unknown) => void;
write = () => new Promise(resolve => { confirm = resolve; });
let pending!: Promise<void>;
await act(async () => { pending = latest.select({ executorId: 'external', configuration: { model: 'fast' } }); });
assert.equal(latest.selection?.configuration.model, 'selected', 'pending write is not displayed');
assert.equal(latest.changing, true);
await act(async () => {
await assert.rejects(latest.select({ executorId: 'external', configuration: { model: 'other' } }), /pending/);
serverModel = 'fast';
confirm({ ok: true, session: { executorConfig: { model: 'fast' } } });
await pending;
});
assert.equal(latest.selection?.configuration.model, 'fast', 'confirmed state replaces stale Session props');
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');
await act(async () => { pending = latest.select({ executorId: 'external', configuration: { model: 'fast' } }); });
await render('next-draft');
await act(async () => {
confirm({ ok: true, session: { executorConfig: { model: 'fast' } } });
await pending;
});
assert.equal(latest.selection, 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('native task creation preserves untouched, provider-default and explicit thinking choices', () => {
for (const level of [undefined, null, 'high'] as const) {
const configuration = newTaskConfiguration({
newChatModel: null, pendingNewChatThinkingLevel: level,
newChatPermissionChoice: undefined, newChatCollaborationMode: 'agent',
newChatOrchestrationMode: 'default',
});
assert.equal(configuration.thinkingLevel, level);
assert.equal(Object.hasOwn(configuration, 'executorId'), false);
}
});
test('an executor choice uses its exact model without inheriting native thinking or orchestration', () => {
const configuration = newTaskConfiguration({
executorSelection: { executorId: 'external', configuration: { model: 'selected' } },
newChatModel: { llmConnectionId: 'native', llmConnectionSlug: 'native', model: 'native-model' },
pendingNewChatThinkingLevel: 'high', newChatPermissionChoice: undefined,
newChatCollaborationMode: 'plan', newChatOrchestrationMode: 'swarm',
});
assert.ok('executorId' in configuration);
assert.equal(configuration.executorId, 'external');
assert.deepEqual(configuration.executorConfig, { model: 'selected' });
assert.equal(Object.hasOwn(configuration, 'thinkingLevel'), false);
assert.equal(configuration.collaborationMode, 'agent');
assert.equal(configuration.orchestrationMode, 'default');
});
@@ -57,9 +57,11 @@ test('setup IPC registers an expectation before start and releases it on termina
});
let phase: ExternalAgentSetupProjection['phase'] = 'connecting';
let attempts = 0;
let catalogChanges = 0;
const input = { attemptId: 'attempt-1', action: 'login' as const, expectedExecutable: '/agent' };
registerExternalAgentSetupIpc({
selectExecutable: async () => "/existing/agy_acp_server.par",
onCatalogChanged: () => { catalogChanges++; },
ipcMain: {
handle: (channel, listener) => {
handlers.set(channel, listener);
@@ -91,10 +93,14 @@ test('setup IPC registers an expectation before start and releases it on termina
// with a regular model OAuth expectation.
const concurrent = presentation.expect('model-after-presentation');
await invoke('external-agents:setup:query', { attemptId: input.attemptId });
assert.equal(catalogChanges, 0);
concurrent.cancel();
phase = 'succeeded';
await invoke('external-agents:setup:query', { attemptId: input.attemptId });
await invoke('external-agents:setup:query', { attemptId: input.attemptId });
assert.equal(catalogChanges, 1, 'one successful setup invalidates the model catalog once');
await invoke('external-agents:setup:start', { ...input, attemptId: 'attempt-2' });
assert.equal(catalogChanges, 2, 'an immediately successful setup also invalidates the catalog');
await invoke('external-agents:setup:cancel', { attemptId: 'attempt-2' });
const next = presentation.expect('regular-oauth');
next.cancel();
@@ -28,7 +28,7 @@ import {
type ModelCatalogEntry,
} from '@maka/core/model-catalog';
import { buildChatModelChoices } from '@maka/core/chat-model-choice';
import { pickNewChatModel } from '../../renderer/shell-chat-model-selection.js';
import { pickNewChatModel } from '../../renderer/features/conversation/index.js';
import { buildCatalogDailyReviewModelOptions } from '../../renderer/model-catalog-choices.js';
function connection(
@@ -19,7 +19,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveNewChatExecutionThinkingLevel } from '../../renderer/use-shell-chat-model.js';
import { resolveNewChatExecutionThinkingLevel } from '../../renderer/features/conversation/index.js';
test('new plugin-executor Sessions preserve the selected thinking level', () => {
assert.equal(
@@ -34,7 +34,7 @@ import { useAppShellComposerQuotes } from '../../renderer/use-app-shell-composer
import {
composerModelSupportsVision,
type NewChatModel,
} from '../../renderer/shell-chat-model-selection.js';
} from '../../renderer/features/conversation/index.js';
/**
* #3408 for what the composer STAGES. The draft text is covered by
@@ -121,9 +121,27 @@ async function loadBridge(changeDuringRead: 'guest' | 'owner') {
crypto: globalThis.crypto,
});
assert.ok(bridge);
return { bridge, catalogReads: () => catalogReads, scopedCalls };
return {
bridge,
catalogReads: () => catalogReads,
scopedCalls,
emitOwnerCatalogChanged: () => emit('external-agents:catalog-changed', owner),
};
}
test('successful external-agent setup invalidates the new-task executor catalog', async () => {
const { bridge, emitOwnerCatalogChanged } = await loadBridge('guest');
await bridge.newTasks.getCatalog();
let changes = 0;
const unsubscribe = bridge.newTasks.subscribeChanges(() => { changes++; });
try {
emitOwnerCatalogChanged();
assert.equal(changes, 1);
} finally {
unsubscribe();
}
});
test('offline Guest notifications cannot starve the Local new-task catalog or redirect onboarding', async () => {
const { bridge, catalogReads, scopedCalls } = await loadBridge('guest');
let invalidations = 0;
@@ -20,7 +20,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { SessionSendProjection } from '@maka/core/session-send-projection';
import { deriveSessionHealthNotice } from '../../renderer/session-health-notice.js';
import { deriveSessionHealthNotice } from '../../renderer/features/conversation/index.js';
const legacySession = {
backend: 'ai-sdk',
@@ -32,7 +32,8 @@ import {
type ComposerHandle,
} from '@maka/ui';
import { SessionHealthRecoveryNotice } from '../../renderer/chat-recovery-notice.js';
import { useShellChatModel } from '../../renderer/use-shell-chat-model.js';
import { ConversationServicesProvider, type ConversationServices } from '../../renderer/features/conversation/index.js';
import { useShellChatModel } from '../../renderer/features/conversation/index.js';
const originalGlobals = {
document: globalThis.document,
@@ -179,7 +180,8 @@ async function renderFlow(props: Parameters<typeof RecoveryFlow>[0]) {
const container = document.querySelector('#root');
assert.ok(container);
mountedRoot = createRoot(container);
await act(() => mountedRoot?.render(createElement(RecoveryFlow, props)));
const services = { subscribeChanges: () => () => {}, sessions: {}, newTasks: {subscribeChanges: () => () => {}} } as unknown as ConversationServices;
await act(() => mountedRoot?.render(createElement(ConversationServicesProvider, {services, children: createElement(RecoveryFlow, props)})));
const action = [...document.querySelectorAll<HTMLButtonElement>('button')]
.find((button) => button.textContent?.includes(
props.snapshotReady
@@ -20,7 +20,7 @@
import { strict as assert } from 'node:assert';
import { describe, it } from 'node:test';
import type { RuntimeHostProfileKind } from '@maka/runtime-host/profile-kind';
import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../renderer/new-task-reload-intent.js';
import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../renderer/application/contracts/new-task-reload-intent.js';
import {
resolveProjectSelection,
selectAvailableProfile,
@@ -20,6 +20,7 @@
import {
decodeExternalAgentSetupStart,
decodeExternalAgentSetupAttempt,
type ExternalAgentSetupProjection,
} from '@maka/runtime-host/protocol';
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
import { handleReconnectableRead, type ReconnectableReadIpcMain } from './ipc-reconnect-policy.js';
@@ -36,9 +37,17 @@ export function registerExternalAgentSetupIpc(deps: {
>;
presentation: RuntimeHostOAuthPresentation;
selectExecutable?: () => Promise<string | undefined>;
onCatalogChanged?: () => void;
}): void {
deps.ipcMain.handle('external-agents:select-executable', () => deps.selectExecutable?.());
let pending: { id: string; expectation: OAuthPresentationExpectation } | undefined;
const announced = new Set<string>();
const announceSuccess = (result: ExternalAgentSetupProjection) => {
if (result.phase !== 'succeeded' || announced.has(result.attemptId)) return;
announced.add(result.attemptId);
if (announced.size > 32) announced.delete(announced.values().next().value!);
deps.onCatalogChanged?.();
};
const clear = (id: string) => {
if (pending?.id !== id) return;
pending.expectation.cancel();
@@ -61,6 +70,7 @@ export function registerExternalAgentSetupIpc(deps: {
try {
const result = await deps.client.startExternalAgentSetup(input);
if (['succeeded', 'failed', 'cancelled'].includes(result.phase)) clear(input.attemptId);
announceSuccess(result);
return result;
} catch (error) {
clear(input.attemptId);
@@ -78,6 +88,7 @@ export function registerExternalAgentSetupIpc(deps: {
try {
const result = await deps.client.queryExternalAgentSetup(attemptId);
if (['succeeded', 'failed', 'cancelled'].includes(result.phase)) clear(attemptId);
announceSuccess(result);
return result;
} catch (error) {
clear(attemptId);
@@ -1618,6 +1618,7 @@ function registerHostClientIpc(
preview: { service: managedArtifactPreview, scope: scope.targetEpoch, openExternal: (url) => shell.openExternal(url) },
});
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];
@@ -877,6 +877,7 @@ export async function createDesktopRuntimeHostCandidate(
registerRuntimeHostSessionCatalogIpc(
{
client,
queryExecutors: (input) => client.request('plugin.executor.query', input),
runningTurnIds: (sessionId) => sessionObserver.observedRunningTurnIds(sessionId),
resolveCreateProject: (input) => deps.resolveSessionCreateProject(input, target),
emitSessionsChanged,
@@ -17,6 +17,7 @@
* under the License.
*/
import { isExecutorConfiguration } from '@maka/core/executor-catalog';
import { randomUUID } from 'node:crypto';
import { isCollaborationMode } from '@maka/core/collaboration';
import { isOrchestrationMode } from '@maka/core/orchestration';
@@ -73,6 +74,7 @@ export interface DesktopHostSessionSummary extends SessionCatalogSummary {
export interface RuntimeHostSessionCatalogIpcDeps {
client: RuntimeHostSessionCatalogClient;
queryExecutors?: (input: import('@maka/runtime-host/protocol').PluginExecutorQueryInput) => Promise<import('@maka/runtime-host/protocol').PluginExecutorQueryResult>;
/** Observer state supplements the Host catalog without falling back to the durable header. */
runningTurnIds: (sessionId: string) => readonly string[];
resolveCreateProject: (
@@ -114,6 +116,13 @@ export function registerRuntimeHostSessionCatalogIpc(
const actionIds = (sessionId: string, options: unknown) =>
resolveSessionActionIds(() => listSessions(), sessionId, options);
handleReconnectableRead(ipcMain, 'sessions:executorCatalog', async (_event, cwd: string) => {
if (typeof cwd !== 'string' || !cwd) throw new Error('Executor discovery requires a workspace');
return (await deps.queryExecutors?.({ kind: 'catalog', cwd }))?.items ?? [];
});
handleReconnectableRead(ipcMain, 'sessions:executorState', async (_event, sessionId: string) =>
(await deps.queryExecutors?.({ kind: 'conversation', sessionId }))?.items ?? [],
);
handleReconnectableRead(ipcMain, 'sessions:list', (_event, filter?: unknown) =>
listSessions(normalizeSessionListFilter(filter)),
);
@@ -232,6 +241,10 @@ export function registerRuntimeHostSessionCatalogIpc(
);
},
);
ipcMain.handle('sessions:setExecutorModelConfiguration', async (_event, sessionId: string, config: unknown) => {
if (!isExecutorConfiguration(config)) throw new Error('Invalid executor configuration');
return updateConfiguration(deps, sessionId, { executorConfig: config }, 'updated');
});
ipcMain.handle('sessions:setThinkingLevel', async (_event, sessionId: string, level: unknown) => {
if (level !== undefined && level !== null && !isThinkingLevel(level)) {
throw new Error(`Invalid thinking level: ${String(level)}`);
@@ -421,6 +434,7 @@ export function resolveDesktopSessionCreateInput(input: CreateSessionRequestInpu
...(executorId
? {
executorId,
...(input?.executorConfig ? { executorConfig: input.executorConfig } : {}),
...(normalizeOptionalString(input?.model, 'executor model')
? { executorModel: normalizeOptionalString(input?.model, 'executor model') }
: {}),
+3
View File
@@ -1012,6 +1012,7 @@ export interface MakaBridge {
};
newTasks: {
getExecutors(target: DesktopNewTaskTarget, cwd: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
getCatalog(): Promise<DesktopNewTaskCatalog>;
subscribeChanges(handler: () => void): () => void;
addProject(host: DesktopNewTaskHostRef, name?: string): Promise<
@@ -1149,6 +1150,8 @@ export interface MakaBridge {
};
sessions: {
setExecutorModelConfiguration(sessionId: string, config: import('@maka/core/executor-catalog').ExecutorConfiguration): Promise<DesktopSessionUpdateResult<DesktopSessionSummary>>;
getExecutorState(sessionId: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
list(filter?: SessionListFilter): Promise<DesktopSessionSummary[]>;
get(sessionId: string): Promise<DesktopSessionSummary | null>;
listWithCoverage(): Promise<{
+6
View File
@@ -1889,6 +1889,9 @@ const makaBridge = {
},
},
newTasks: {
async getExecutors(target, cwd) {
return ipcRenderer.invoke('sessions:executorCatalog', await runtimeHostScope(target), cwd);
},
getCatalog(): Promise<DesktopNewTaskCatalog> {
return loadNewTaskCatalog();
},
@@ -1899,6 +1902,7 @@ const makaBridge = {
subscribeEveryRuntimeHostEvent('connections:event', handler),
subscribeEveryRuntimeHostEvent('mcp:changed', handler),
subscribeEveryRuntimeHostEvent('settings:externalChanged', handler),
subscribeEveryRuntimeHostEvent('external-agents:catalog-changed', handler),
];
return () => {
newTaskChangeListeners.delete(handler);
@@ -2197,6 +2201,8 @@ const makaBridge = {
},
} satisfies import('../shared/session-local-contract.js').DesktopSessionLocalBridge,
sessions: {
setExecutorModelConfiguration(sessionId, config) { return invokeSessionUpdate('sessions:setExecutorModelConfiguration', sessionId, config); },
getExecutorState(sessionId) { return invokeSessionRuntimeHost('sessions:executorState', sessionId); },
list(filter?: SessionListFilter): Promise<DesktopSessionSummary[]> {
return listDesktopSessions(filter);
},
@@ -72,10 +72,7 @@ type RefBox<T> = { current: T };
type MessageLoadErrorUpdater = (updater: (current: Record<string, string>) => Record<string, string>) => void;
type InteractionQueueUpdater = (updater: (current: InteractionQueues) => InteractionQueues) => void;
type PendingNewChatModel =
| { llmConnectionId: string; llmConnectionSlug: string; model: string }
| { executorId: string; model: string }
| null;
type PendingNewChatModel = Conversation.NewChatExecutionTarget | null;
type PendingNewChatThinkingLevel = ThinkingLevel | null | undefined;
type DesktopNewTaskTarget = DesktopBridge.DesktopNewTaskTarget;
@@ -183,6 +180,8 @@ export function createAppShellChatActions(deps: {
) => void;
toastApi: ToastApi;
newChatModel: PendingNewChatModel;
executorSelection?: { executorId: string; configuration: import('@maka/core/executor-catalog').ExecutorConfiguration };
executorEntry?: Conversation.ExecutorSubmission['executorEntry'];
/** Undefined applies the Host's model default; null explicitly keeps the provider default. */
pendingNewChatThinkingLevel: PendingNewChatThinkingLevel;
/**
@@ -223,12 +222,8 @@ export function createAppShellChatActions(deps: {
respondToUserForm: submitUserForm,
showModelSetupToast,
toastApi,
newChatModel,
pendingNewChatThinkingLevel,
newChatPermissionChoice,
clearNewChatPermissionChoice,
newChatCollaborationMode,
newChatOrchestrationMode,
newTaskTarget,
} = deps;
const copy = getShellCopy(uiLocale).chatActions;
@@ -309,6 +304,7 @@ export function createAppShellChatActions(deps: {
): Promise<boolean> {
const { directoryReferences, quotes } = options;
const initialSessionId = options.targetSessionId ?? activeIdRef.current;
if (!Conversation.canSubmitExecutor(deps, pending?.length ?? 0)) return false;
const sendOwner = captureComposerImportOwner();
const selectionIsCurrent = captureSelection();
if (!initialSessionId && !newTaskTarget) return false;
@@ -379,11 +375,7 @@ export function createAppShellChatActions(deps: {
if (pending?.length) preflightAttachmentItems(pending);
const session = await window.maka.newTasks.create(newTaskTarget, {
name: DEFAULT_SESSION_NAME,
...(newChatModel !== null ? { ...newChatModel } : {}),
thinkingLevel: pendingNewChatThinkingLevel,
...(newChatPermissionChoice ? { permissionMode: newChatPermissionChoice } : {}),
collaborationMode: newChatCollaborationMode,
orchestrationMode: newChatOrchestrationMode,
...Conversation.newTaskConfiguration(deps),
});
unsentSessionId = session.id;
// Creation can also yield while a same-target New Task is reopened.
+13 -17
View File
@@ -93,7 +93,6 @@ import * as TaskEntry from './features/task-entry';
import type { TaskEntryShellProjection } from './features/task-entry';
import * as Overlays from './features/overlays/index.js';
import type { OverlaysShellProjection } from './features/overlays/index.js';
import { useNewTaskChoice } from './use-new-task-choice';
import { SessionCollaborationDialog } from './session-collaboration-dialog';
import * as SessionCollaboration from './features/session-collaboration';
import { NEW_TASK_PENDING_KEY } from './pending-items';
@@ -179,6 +178,8 @@ import {
desktopSlashCommandPresentation,
useActiveExecutionBoundary,
useComposerAttachments,
useNewTaskChoice,
useShellChatModel,
} from './features/conversation/index.js';
import { useAppShellComposerQuotes } from './use-app-shell-composer-quotes';
import {
@@ -188,7 +189,6 @@ import {
import { useAppShellSessionWorkspace } from './use-app-shell-session-workspace';
import { useShellMemoryPill } from './use-shell-memory-pill';
import { useShellConnections } from './use-shell-connections';
import { useShellChatModel } from './use-shell-chat-model';
import { useShellLiveTurn } from './use-shell-live-turn';
import { useShellResume } from './use-shell-resume';
@@ -723,14 +723,15 @@ function AppShellContent({
activeConnectionLabel,
activeModel,
activeModelLabel,
activeThinkingLevels,
activeThinkingLevel,
executor,
composerModelProps,
newChatModel,
newChatExecutionTarget,
newChatModelLabel,
newChatProviderType,
newChatThinkingLevels,
newChatThinkingLevel,
pendingNewChatThinkingLevel,
newChatExecutionThinkingLevel,
composerSupportsVision,
setPendingNewChatModel,
@@ -745,6 +746,8 @@ function AppShellContent({
sessionSendOutcome: activeSessionSendOutcome,
defaultConnection,
newTaskKey: currentNewTaskDraftKey,
executorTarget: taskEntry.selectors.target,
executorCwd: activeSession?.cwd ?? taskEntry.selectors.projectPath,
activationCandidate: modelSettingsOwnsComposerHost
? onboardingActivationCandidate
: undefined,
@@ -1372,7 +1375,9 @@ function AppShellContent({
showModelSetupToast,
toastApi,
newChatModel: newChatExecutionTarget ?? null,
pendingNewChatThinkingLevel: newChatExecutionThinkingLevel ?? null,
pendingNewChatThinkingLevel: executorTarget ? newChatExecutionThinkingLevel ?? null : pendingNewChatThinkingLevel,
executorSelection: executor.selection,
executorEntry: executor.entry,
newChatPermissionChoice: newTaskPermissionChoice,
clearNewChatPermissionChoice: clearNewTaskPermissionChoice,
newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent',
@@ -2410,28 +2415,21 @@ function AppShellContent({
onPasteAsQuote={canStageComposerContext ? onAddQuote : undefined}
onPickAttachments={contextPickEnabled ? pickAttachments : undefined}
onAttachFilePaths={contextPickEnabled ? attachFilePaths : undefined}
modelLabel={activeModelLabel ?? newChatModelLabel}
{...Conversation.executorComposerProps(executor, {activeId, turnActive, taskSubmissionHardBlocked, connectionCount: connections.length, onSetup: () => openSettingsSection('external-agents'), onNewTask: openNewTaskSurface})}
activeSession={activeSessionForView}
{...{ executorTarget, onExecutorTargetChange }}
activeModelConnectionId={activeSessionForModelControls?.llmConnectionId}
activeModelConnectionSlug={activeSessionForModelControls?.llmConnectionSlug}
activeModel={activeModel}
activeModelLabel={activeModelLabel}
activeProviderType={activeConnection?.providerType}
latestRequestUsageTokens={selectLatestRequestUsage(messages, activeModel, activeSessionForModelControls)}
onOpenContextUsage={() => commands.toggleTool('inspector')}
LiveContextUsageProbe={LiveContextUsageProbe}
contextUsageSessionId={ownerActiveId}
modelChoices={chatModelChoices}
modelSwitchHasHistory={modelSwitchHasHistory}
hideUnavailableCurrentModel={sessionHealthNotice?.onClickTarget === 'model_picker'}
renderProviderMark={(type) => <ProviderBrandMark type={type} />}
onModelChange={(input) => activeId ? void setSessionModel(activeId, input) : undefined}
{...{ modelSwitchAvailability, activeThinkingLevels, activeThinkingLevel }}
modelSwitchAvailability={modelSwitchAvailability}
onThinkingLevelChange={(level) => {
if (activeId) void setSessionThinkingLevel(activeId, level ?? null);
}}
{...{ newChatModel, newChatProviderType, newChatThinkingLevels, newChatThinkingLevel }}
{...composerModelProps}
onPickNewChatModel={(input) => {
setPendingNewChatModel(input);
if (modelSettingsOwnsComposerHost) saveComposerDefaults({ model: input });
@@ -2440,11 +2438,9 @@ function AppShellContent({
onOpenModelSettings={modelSettingsOwnsComposerHost
? () => openSettingsSection('models')
: undefined}
noModelConnection={!activeId && connections.length === 0}
noModelHint={!modelSettingsOwnsComposerHost && composerProfileName
? shellCopy.configureModelsOnHost(composerProfileName)
: undefined}
sendBlocked={taskSubmissionHardBlocked}
permissionMode={activePermissionMode}
// Every "cannot change this mid-turn" gate reads `turnActive`,
// the same witness Stop reads. Reading the persisted status
@@ -35,11 +35,7 @@ function rendererSessionStorage(): SessionStorageLike | undefined {
}
}
/**
* A renderer-reload lease for the explicit empty new-task surface.
* sessionStorage survives HMR/navigation reloads but not a new application
* window, so ordinary cold-start history restoration remains unchanged.
*/
/** Renderer-reload lease shared by Task Entry and the conversation draft. */
export function hasNewTaskReloadIntent(
storage: SessionStorageLike | undefined = rendererSessionStorage(),
): boolean {
@@ -73,8 +69,6 @@ export function readNewTaskReloadIntent(
try {
const raw = storage?.getItem(NEW_TASK_RELOAD_INTENT_KEY);
if (!raw) return undefined;
// The original marker shipped as the literal `1`. Keep reloads made by
// that version fail-soft while treating the marker as surface state only.
if (raw === '1') return { draft: '' };
const parsed = JSON.parse(raw) as Partial<NewTaskReloadIntent>;
if (typeof parsed.draft !== 'string') return undefined;
@@ -95,9 +89,8 @@ export function writeNewTaskReloadDraft(
try {
const intent = readNewTaskReloadIntent(storage);
if (!intent) return;
const persistedKey = draftKey === UNRESOLVED_NEW_TASK_DRAFT_KEY
? intent.draftKey ?? draftKey
: draftKey;
const persistedKey =
draftKey === UNRESOLVED_NEW_TASK_DRAFT_KEY ? (intent.draftKey ?? draftKey) : draftKey;
storage?.setItem(
NEW_TASK_RELOAD_INTENT_KEY,
JSON.stringify({ draft, draftKey: persistedKey }),
@@ -37,7 +37,7 @@ import {
readNewTaskReloadIntent,
UNRESOLVED_NEW_TASK_DRAFT_KEY,
writeNewTaskReloadDraft,
} from './new-task-reload-intent.js';
} from './application/contracts/new-task-reload-intent.js';
const newTaskDraftPersistence = {
read(key: string | undefined): string | undefined {
@@ -29,7 +29,7 @@ import {
} from '@maka/ui';
import { OnboardingHero } from './onboarding-hero';
import type { AppShellSessionUiState, AppShellSessionUiStateController } from './app-shell-session-ui-state';
import type { SessionHealthNoticeView } from './use-shell-chat-model';
import type { SessionHealthNoticeView } from './features/conversation/index.js';
import type { WorkspaceReadinessRecovery } from './workspace-readiness-recovery';
import type { TaskReadinessNotice } from './task-readiness-notice';
import { getShellCopy } from './locales/shell-copy';
@@ -19,7 +19,7 @@
import type { ReactNode } from 'react';
import { Banner, Button } from '@maka/ui';
import type { SessionHealthNoticeView } from './use-shell-chat-model';
import type { SessionHealthNoticeView } from './features/conversation/index.js';
/** One composer-adjacent recovery surface: shared placement and action hierarchy. */
export function ChatRecoveryNotice(props: {
@@ -0,0 +1,198 @@
/*
* 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 { useCallback, useEffect, useRef, useState } from 'react';
import type { ExecutorCatalogEntry, ExecutorSelection } from '@maka/core/executor-catalog';
import type { SessionSummary } from '@maka/core/session';
import type { ConversationNewTaskTarget } from '../ports.js';
import { useConversationServices } from '../services.js';
export function useExecutorSelection(input: {
key: string;
target?: ConversationNewTaskTarget;
cwd?: string;
session?: SessionSummary;
}) {
const services = useConversationServices();
const [draft, setDraft] = useState<{ key: string; selection?: ExecutorSelection }>();
const [snapshot, setSnapshot] = useState<{
key: string;
catalog: readonly ExecutorCatalogEntry[];
loading: boolean;
error?: string;
}>();
const [changingKey, setChangingKey] = useState<string>();
const inFlight = useRef<string | undefined>(undefined);
const sessionId = input.session?.id;
const executorId = input.session?.executorId;
const key = sessionId ?? input.key;
const current = useRef(key);
current.current = key;
const revision = useRef(0);
const refreshes = useRef(new Map<string, Promise<void>>());
const pendingInvalidations = useRef(new Set<string>());
const refresh = useCallback((): Promise<void> => {
const existing = refreshes.current.get(key);
if (existing) return existing;
const run = (async () => {
const attempt = ++revision.current;
if (sessionId && !executorId) {
setSnapshot({ key, catalog: [], loading: false });
return;
}
if (!sessionId && (!input.target || !input.cwd)) return;
setSnapshot((previous) => ({
key,
catalog: previous?.key === key ? previous.catalog : [],
loading: true,
}));
try {
const catalog = sessionId
? ((await services.sessions.getExecutorState?.(sessionId)) ?? [])
: ((await services.newTasks.getExecutors?.(input.target!, input.cwd!)) ?? []);
if (current.current === key && revision.current === attempt)
setSnapshot({ key, catalog, loading: false });
} catch (error) {
if (current.current === key && revision.current === attempt)
setSnapshot({
key,
catalog: [],
loading: false,
error: error instanceof Error ? error.message : 'Executor unavailable',
});
}
})();
let tracked!: Promise<void>;
tracked = run.finally(() => {
if (refreshes.current.get(key) !== tracked) return;
refreshes.current.delete(key);
if (pendingInvalidations.current.delete(key) && current.current === key) void refresh();
});
refreshes.current.set(key, tracked);
return tracked;
}, [
key,
sessionId,
executorId,
input.session?.executorConfig?.model,
input.target?.hostId,
input.target?.profileId,
input.target?.projectId,
input.cwd,
services,
]);
const invalidate = useCallback(() => {
if (refreshes.current.has(key)) pendingInvalidations.current.add(key);
else void refresh();
}, [key, refresh]);
useEffect(() => {
const unsubscribe = services.newTasks.subscribeChanges(() => {
invalidate();
});
const unSession = services.subscribeChanges((changedSessionId) => {
if (sessionId && changedSessionId === sessionId) invalidate();
});
// Conversation inspection is process-free, including while a retained process is idle.
let timer: ReturnType<typeof setTimeout> | undefined;
let stopped = false;
const poll = async () => {
await refresh();
if (!stopped) timer = setTimeout(() => void poll(), 3000);
};
if (sessionId && executorId) void poll();
else void refresh();
return () => {
stopped = true;
revision.current++;
refreshes.current.delete(key);
pendingInvalidations.current.delete(key);
unsubscribe();
unSession();
clearTimeout(timer);
};
}, [refresh, invalidate, services, sessionId, executorId, key]);
useEffect(() => {
if (sessionId) setDraft(undefined);
}, [sessionId]);
const catalog = snapshot?.key === key ? snapshot.catalog : [];
const inspected = catalog.find(candidate => candidate.id === executorId);
const selection = executorId
? { executorId, configuration: inspected?.readiness === 'ready' && inspected.currentModel
? { model: inspected.currentModel } : input.session?.executorConfig ?? {} }
: sessionId
? undefined
: draft?.key === input.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 });
return;
}
if (!next || next.executorId !== executorId || !services.sessions.setExecutorModelConfiguration)
throw new Error('Executor configuration is unavailable');
inFlight.current = key;
setChangingKey(key);
try {
const result = await services.sessions.setExecutorModelConfiguration(
sessionId,
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 (current.current === key) {
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),
}));
await refresh();
}
} catch (error) {
if (current.current === key) await refresh();
if (current.current === key)
setSnapshot((previous) => ({
key,
catalog: previous?.key === key ? previous.catalog : [],
loading: false,
error: error instanceof Error ? error.message : 'Executor configuration failed',
}));
throw error;
} finally {
if (inFlight.current === key) inFlight.current = undefined;
setChangingKey(previous => previous === key ? undefined : previous);
}
};
const entry = catalog.find((candidate) => candidate.id === selection?.executorId);
return {
selection,
catalog,
entry,
select,
refresh,
changing: changingKey === key,
loading: snapshot?.key !== key || snapshot.loading,
error: snapshot?.key === key ? snapshot.error : undefined,
};
}
@@ -18,7 +18,7 @@
*/
import { useCallback, useEffect, useState } from 'react';
import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from './new-task-reload-intent.js';
import { UNRESOLVED_NEW_TASK_DRAFT_KEY } from '../../../application/contracts/new-task-reload-intent.js';
export function useNewTaskChoice<T>(
targetKey: string,
@@ -17,6 +17,8 @@
* under the License.
*/
import { useExecutorSelection } from './use-executor-selection.js';
import type { ComposerProps } from '@maka/ui';
import { useMemo } from 'react';
import type { ChatModelChoice } from '@maka/core/chat-model-choice';
import type {
@@ -35,16 +37,16 @@ import {
pickNewChatModel,
type NewChatModel,
type NewChatModelCandidate,
} from './shell-chat-model-selection.js';
} from '../model/shell-chat-model-selection.js';
import {
deriveSessionHealthNotice,
type SessionHealthNoticeTarget,
} from './session-health-notice.js';
import type { ComposerDefaults } from './composer-defaults.js';
import { getDesktopConversationCopy } from './locales/conversation-copy.js';
} from '../model/session-health-notice.js';
type ComposerDefaults = { model: NewChatModelCandidate | null };
import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
import { useNewTaskChoice } from './use-new-task-choice.js';
export type { NewChatModel } from './shell-chat-model-selection.js';
export type { NewChatModel } from '../model/shell-chat-model-selection.js';
export type NewChatExecutionTarget = NewChatModel | { executorId: string; model: string };
export function resolveNewChatExecutionThinkingLevel(
@@ -82,6 +84,8 @@ export function useShellChatModel(options: {
sessionSendOutcome: SessionSendProjection | undefined;
defaultConnection: string | null;
newTaskKey: string;
executorTarget?: import('../ports.js').ConversationNewTaskTarget;
executorCwd?: string;
activationCandidate?: NewChatModelCandidate;
activeSession: SessionSummary | undefined;
sessionHealthSession: SessionSummary | undefined;
@@ -94,6 +98,8 @@ export function useShellChatModel(options: {
refreshModelChoices(): void | Promise<void>;
setSessionExecutor?(sessionId: string, target: MakaClientExecutorTarget): Promise<boolean>;
}): {
executor: ReturnType<typeof useExecutorSelection>;
composerModelProps: Pick<ComposerProps, 'modelLabel' | 'activeModelConnectionId' | 'activeModelConnectionSlug' | 'activeModel' | 'activeModelLabel' | 'activeProviderType' | 'modelChoices' | 'hideUnavailableCurrentModel' | 'activeThinkingLevels' | 'activeThinkingLevel' | 'newChatModel' | 'newChatProviderType' | 'newChatThinkingLevels' | 'newChatThinkingLevel'>;
chatModelChoices: ChatModelChoice[];
activeConnection: IdentifiedLlmConnection | undefined;
activeConnectionLabel: string | undefined;
@@ -118,6 +124,7 @@ export function useShellChatModel(options: {
onExecutorTargetChange: (target: MakaClientExecutorTarget) => Promise<void>;
sessionHealthNotice: SessionHealthNoticeView | undefined;
} {
const executor = useExecutorSelection({ key: options.newTaskKey, target: options.executorTarget, cwd: options.executorCwd, session: options.activeSession });
const {
uiLocale,
connections,
@@ -337,6 +344,21 @@ export function useShellChatModel(options: {
]);
return {
executor: { ...executor, select: async (next) => {
if (!activeSession && pendingExecutorTarget) setPendingExecutionChoice(null);
await executor.select(next);
} },
composerModelProps: {
modelLabel: activeModelLabel ?? newChatModelLabel,
activeModelConnectionId: options.activeSession?.llmConnectionId,
activeModelConnectionSlug: options.activeSession?.llmConnectionSlug,
activeModel, activeModelLabel, activeProviderType: activeConnection?.providerType,
modelChoices: chatModelChoices,
hideUnavailableCurrentModel: sessionHealthNotice?.onClickTarget === 'model_picker',
activeThinkingLevels, activeThinkingLevel,
newChatModel, newChatProviderType: connections.find((connection) => connection.slug === newChatModel?.llmConnectionSlug)?.providerType,
newChatThinkingLevels, newChatThinkingLevel,
},
chatModelChoices,
activeConnection,
activeConnectionLabel,
@@ -369,6 +391,7 @@ export function useShellChatModel(options: {
if (activeSession) {
await options.setSessionExecutor?.(activeSession.id, target);
} else {
await executor.select(undefined);
setPendingExecutionChoice(target);
setPendingNewChatThinkingLevel(target.thinkingLevel ?? null);
}
@@ -74,3 +74,11 @@ export { activeHostTurn, chatTurnActivity } from '../../application/contracts/se
export { selectLiveTurns, sessionUiSelectors } from './model/session-ui-selectors.js';
export { LiveTurnReconciler } from './controller/live-turn-reconciler.js';
export { sessionIdSetsEqual, type LiveTurnSnapshot } from './model/live-turn-snapshot.js';
export { useExecutorSelection } from './controller/use-executor-selection.js';
export * from './model/shell-chat-model-selection.js';
export * from './model/session-health-notice.js';
export * from './controller/use-new-task-choice.js';
export * from './controller/use-shell-chat-model.js';
export * from './model/executor-submission.js';
export * from './model/executor-composer.js';
@@ -0,0 +1,61 @@
/*
* 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 { ComposerProps } from '@maka/ui';
import type { useExecutorSelection } from '../controller/use-executor-selection.js';
export function executorComposerProps(
executor: ReturnType<typeof useExecutorSelection>,
input: {
activeId: string | undefined;
turnActive: boolean;
taskSubmissionHardBlocked: boolean;
connectionCount: number;
onSetup(): void;
onNewTask(): void;
},
): Pick<ComposerProps, 'executorPicker' | 'sendBlocked' | 'noModelConnection'> {
const fixed = !!input.activeId;
return {
executorPicker:
!fixed || executor.selection
? {
catalog: executor.catalog,
selection: executor.selection,
fixed,
disabled: executor.changing || (fixed && input.turnActive),
loading: executor.loading,
error: executor.error,
onSelect: (selection) => executor.select(selection),
onRetry: () => {
void executor.refresh();
},
onSetup: input.onSetup,
onNewTask: input.onNewTask,
}
: undefined,
noModelConnection: !executor.selection && !fixed && input.connectionCount === 0,
sendBlocked:
input.taskSubmissionHardBlocked ||
!!(
executor.selection &&
(executor.entry?.readiness !== 'ready' || (fixed && input.turnActive))
),
};
}
@@ -0,0 +1,90 @@
/*
* 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, ExecutorSelection } from '@maka/core/executor-catalog';
import type { ChatDefaultPermissionMode } from '@maka/core/settings';
import type { CollaborationMode } from '@maka/core/collaboration';
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';
export interface ExecutorSubmission {
executorSelection?: ExecutorSelection;
executorEntry?: Pick<ExecutorCatalogEntry, 'readiness' | 'supportsAttachments'>;
}
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.',
},
'zh-CN': {
attachments: '请移除不支持的附件或选择 Maka,草稿会保留。',
unavailable: '请检查外部 Agent 设置,或新建任务。',
},
'zh-TW': {
attachments: '請移除不支援的附件或選擇 Maka,草稿會保留。',
unavailable: '請檢查外部 Agent 設定,或建立新任務。',
},
} satisfies UiCatalog<{ attachments: string; unavailable: 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'];
}
export function newTaskConfiguration(
input: ExecutorSubmission & {
newChatModel: NewChatModel | { executorId: string; model: string } | null;
pendingNewChatThinkingLevel: ThinkingLevel | null | undefined;
newChatPermissionChoice: ChatDefaultPermissionMode | undefined;
newChatCollaborationMode: CollaborationMode;
newChatOrchestrationMode: OrchestrationMode;
},
) {
const executor = input.executorSelection;
return {
...(executor
? { executorId: executor.executorId, executorConfig: executor.configuration }
: { ...(input.newChatModel ?? {}), executorConfig: undefined }),
...(!executor ? { thinkingLevel: input.pendingNewChatThinkingLevel } : {}),
...(input.newChatPermissionChoice ? { permissionMode: input.newChatPermissionChoice } : {}),
collaborationMode: executor ? ('agent' as const) : input.newChatCollaborationMode,
orchestrationMode: executor ? ('default' as const) : input.newChatOrchestrationMode,
};
}
export function canSubmitExecutor(
input: ExecutorSubmission & { uiLocale: UiLocale; toastApi: { error(message: string): void } },
attachments: number,
): boolean {
const error = executorSubmissionError(input, attachments, input.uiLocale);
if (error) input.toastApi.error(error);
return !error;
}
@@ -48,7 +48,7 @@ import { type IdentifiedLlmConnection } from '@maka/core/llm-connections';
import { type SessionSendProjection, type SessionSendProjectionSession } from '@maka/core/session-send-projection';
import { type UiLocale } from '@maka/core/ui-locale';
import { getDesktopConversationCopy } from './locales/conversation-copy.js';
import { getDesktopConversationCopy } from '../../../locales/conversation-copy.js';
export interface SessionHealthNoticeInput {
locale: UiLocale;
@@ -56,6 +56,8 @@ export interface ConversationServices extends Pick<
'listMessages' | 'cancelMessage' | 'reconcileMessage' | 'subscribeChanges'
> {
readonly sessions: {
getExecutorState?(sessionId: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
setExecutorModelConfiguration?(sessionId: string, config: import('@maka/core/executor-catalog').ExecutorConfiguration): Promise<import('../../../shared/desktop-session-projection.js').DesktopSessionUpdateResult<DesktopSessionSummary>>;
readSnapshot(sessionId: string, options?: { readonly maxChars?: number }): Promise<SessionSnapshot>;
readExecutionBoundary(sessionId: string): Promise<ExecutionBoundaryReadModel>;
};
@@ -72,6 +74,7 @@ export interface ConversationServices extends Pick<
): Promise<ConversationFileSearchResult>;
};
readonly newTasks: {
getExecutors?(target: ConversationNewTaskTarget, cwd: string): Promise<readonly import('@maka/core/executor-catalog').ExecutorCatalogEntry[]>;
subscribeChanges(handler: () => void): () => void;
listInvocableSkills(
target: ConversationNewTaskTarget,
@@ -22,7 +22,7 @@ import {
markNewTaskReloadIntent,
UNRESOLVED_NEW_TASK_DRAFT_KEY,
writeNewTaskReloadDraft,
} from '../../../new-task-reload-intent.js';
} from '../../../application/contracts/new-task-reload-intent.js';
import type {
TaskEntryCatalog,
TaskEntryHost,
@@ -35,7 +35,10 @@
import type { StoredMessage } from '@maka/core/session';
import type { TransientUserMessageProjection } from '@maka/ui';
import { clearNewTaskReloadIntent, markNewTaskReloadIntent } from './new-task-reload-intent.js';
import {
clearNewTaskReloadIntent,
markNewTaskReloadIntent,
} from './application/contracts/new-task-reload-intent.js';
import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js';
import {
mergeTransientMessageProjection,
@@ -408,6 +408,18 @@
min-width: 100px;
max-width: min(220px, 28vw);
}
/* External model names share this row with an executor selector. Size the
trigger to its label and let the pair wrap instead of squeezing the model
into the native model + thinking pair's width budget. */
.maka-composer-left-controls .maka-model-selection-controls:has(.maka-external-model-selector) {
max-width: 100%;
flex-wrap: wrap;
}
.maka-composer-left-controls .maka-external-model-selector {
width: max-content;
max-width: 100%;
flex: 0 1 auto;
}
.maka-composer-model-chip-text {
min-width: 0;
overflow: hidden;
@@ -124,3 +124,146 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.maka-executor-selector {
min-width: 0;
max-width: min(300px, 48vw);
flex: 0 0 auto;
}
.maka-executor-picker-panel {
display: grid;
grid-template-columns: minmax(150px, 0.7fr) minmax(260px, 1.3fr);
width: 100%;
height: min(320px, calc(70vh - 56px));
min-height: 240px;
overflow: hidden;
}
.maka-executor-picker-rail,
.maka-executor-picker-models {
min-width: 0;
min-height: 0;
}
.maka-executor-picker-rail {
display: flex;
flex-direction: column;
gap: var(--space-1);
padding: var(--space-1) var(--space-2) var(--space-1) 0;
border-right: var(--border-width-hairline) solid var(--border);
overflow-y: auto;
}
.maka-executor-picker-panel .maka-executor-picker-entry,
.maka-executor-picker-panel .maka-executor-picker-model {
height: auto;
min-height: 28px;
flex-shrink: 0;
justify-content: flex-start;
text-align: left;
white-space: normal;
}
.maka-executor-picker-label {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
min-width: 0;
}
.maka-executor-picker-entry[data-active='true'],
.maka-executor-picker-model[aria-selected='true'] {
background: var(--muted);
}
.maka-executor-picker-entry-status {
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.35;
}
.maka-executor-picker-manage {
margin-top: auto;
}
.maka-executor-picker-models {
display: flex;
flex-direction: column;
gap: var(--space-2);
padding-left: var(--space-3);
}
.maka-executor-picker-search {
flex-shrink: 0;
min-width: 0;
}
.maka-executor-picker-model-list {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-1);
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.maka-executor-picker-model small {
color: var(--muted-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maka-executor-picker-native {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--space-2);
min-width: 0;
min-height: 0;
}
.maka-executor-picker-option {
flex-shrink: 0;
}
.maka-executor-picker-option .maka-executor-picker-model {
width: 100%;
}
.maka-executor-picker-group {
padding: var(--space-1) var(--space-2);
color: var(--muted-foreground);
font-size: 11px;
}
.maka-executor-picker-readiness {
display: flex;
flex: 1 1 auto;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
padding-top: var(--space-1);
}
.maka-executor-picker-readiness {
flex-direction: column;
}
@media (max-width: 640px) {
.maka-executor-picker-panel {
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1.2fr);
}
}
.maka-executor-notice {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1);
font-size: 12px;
line-height: 1.5;
}
+4 -2
View File
@@ -348,7 +348,8 @@
flex-shrink: 0;
}
.maka-session-row-host-badge {
.maka-session-row-host-badge,
.maka-session-row-executor-badge {
display: inline-flex;
min-width: 0;
max-width: 88px;
@@ -359,7 +360,8 @@
flex: none;
}
.maka-session-row-host-badge > span {
.maka-session-row-host-badge > span,
.maka-session-row-executor-badge > span {
overflow: hidden;
max-width: 100%;
text-overflow: ellipsis;
@@ -26,7 +26,7 @@ import {
import { useExternalStoreSelector } from './application/contracts/session-catalog/use-external-store-selector.js';
import { useAppShellSessionList } from './use-app-shell-session-list.js';
import { createBootstrapSelectionLease } from './bootstrap-selection-lease.js';
import { hasNewTaskReloadIntent } from './new-task-reload-intent.js';
import { hasNewTaskReloadIntent } from './application/contracts/new-task-reload-intent.js';
import type { DesktopTranscriptRangeController } from './platform/desktop/desktop-transcript-range-store.js';
import {
createSessionWorkspaceActions,
+145
View File
@@ -0,0 +1,145 @@
<!--
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.
-->
# Antigravity ACP Plugin rebuild
## Basis of the Plugin implementation
The original PR #5224 proposal predated the Plugin-backed Session executor architecture from #5283.
It added a dedicated ACP backend and then threads external-Agent identity, model catalogs, session control, protocol
operations, Desktop bridges, and Composer state through the Host. That original approach changed 139 files and added roughly 7,800 lines.
The new architecture already owns those cross-cutting responsibilities generically:
| Concern | Original proposal | Plugin implementation |
| --- | --- | --- |
| Session routing | `AcpAgentBackend` registered in Host execution composition | `executorId` plus `PluginExecutorBackend` |
| Executor lifetime | ACP-specific Host residency and backend registry logic | Plugin Entry generation, retirement, and executor binding |
| Transcript | ACP event conversion plus new external-session fields | Generic executor events enter the canonical Session stream |
| Process and credentials | Runtime Host ACP module | Contributing Plugin; opaque to Maka Runtime |
| Child/Graph execution | ACP-specific guards | Generic executor propagation from #5283 |
| Model choice | Provider catalog protocol and ACP-specific Desktop state | Generic executor catalog/configuration consumed by Desktop and the Plugin backend |
| Permission choices | ACP backend reaches `HostedInteractionBridge` directly | Generic executor permission request bridged by `PluginExecutorBackend` |
Keeping both designs would create two backend authorities, two lifecycle paths, and provider-specific
state in otherwise generic Session and Desktop code.
## Rebuilt boundary
The rebuild is split into an intermediate runtime and a thin product adapter:
```text
PluginExecutorService
-> ACP Runtime Plugin (`ctx.acp`)
-> Antigravity adapter
-> future ACP adapter
```
`@maka/acp-executor-plugin` owns the shared ACP protocol implementation. Its `acp-runtime` profile
Entry provides `ctx.acp`; external-Agent Entries are mounted below it and call
`ctx.acp.register(ctx, ...)`, explicitly preserving the consuming Entry identity across independently
bundled Plugin generations.
The service wraps every adapter as the generic executor contribution introduced by #5283, so it is
not a second backend or routing authority.
`@maka/antigravity-acp-plugin` now contains only executable/helper validation, Antigravity environment
policy, initial model configuration, and Antigravity question/failure recognition. The shared runtime owns:
- ACP initialize, Session creation, prompt, cancellation, and cleanup;
- one retained ACP process/Session per Maka conversation;
- ACP file callbacks with workspace and symlink containment;
- ACP tool/thought/text projection into generic executor events;
- ACP permission/question option identity and settlement;
- cancellation drain with the actual provider stop reason retained in the durable runtime ledger;
- bounded, cached model discovery and Agent-confirmed idle model changes;
- generic initial ACP configuration validation/application;
- durable history-only detection so a Host/Plugin restart cannot silently fork an existing external
conversation into a new ACP Session.
The Antigravity adapter owns:
- the official Antigravity executable and helper paths;
- the `ANTIGRAVITY_HARNESS_PATH`, proxy-bypass, and browser environment policy;
- the optional model value passed to the shared initial-configuration mechanism;
- recognition of official structured questions and provider error text.
The Host owns only executor visibility and binding, Maka Session/run identity, canonical event
persistence, hosted form admission, and Plugin retirement. No ACP process, credential, or external
Session identifier crosses the Plugin boundary.
## Deliberately not ported
The rebuild does not carry forward the PR #5224 provider catalog protocol, external-Agent Session
protocol, renderer hot cache, draft/prewarm Session lease, ACP-specific backend registry branches,
custom storage fields, CLI transcript branches, model-picker forks, or visual-workaround changes.
Those components either duplicate #5283 or solve UI/configuration concerns that should be added as a
generic Plugin executor capability rather than an Antigravity branch.
The installation and authentication surface already merged in #5164 remains the producer of setup
facts. `HostBuiltinExternalAgentPluginCoordinator` projects the saved executable into system-managed
Plugin packages and a configured adapter Entry. The projection is content-addressed and idempotent:
it installs the ACP runtime before the adapter, replaces only a changed package layer, restores the
same state after Host restart, and removes the adapter before its runtime when the setting is cleared.
Adapter code never reads RuntimePolicy.
Both production `plugin.mjs` bundles are release dependencies of Runtime Host, so the same path is
available to Desktop-owned and managed/remote Hosts. The installed ACP service uses an isolated
Context label and an explicit consumer Context instead of relying on cross-bundle `Service`
`instanceof` identity.
## PR 2 producer-to-consumer delivery
| Checklist set | Producer → consumer | Status |
| --- | --- | --- |
| A: setup | RuntimePolicy executable facts → built-in Plugin packages/Entry → executor inspection | Implemented; unavailable/authentication states reach the model menu and setup navigation |
| B: selection | ACP provider discovery → generic catalog → Desktop model menu → atomic Session executor/config → first prompt | Implemented; bounded probe Sessions are temporary and never become Maka tasks |
| C: execution | Retained ACP Session → generic events/Hosted Forms → transcript → original option identity | Implemented; includes question forms, tools/diffs, file callbacks, follow-up, cancellation and cleanup |
| D: continuity | Plugin-owned durable continuity marker → process-free inspection → Session admission/Desktop readiness | Implemented; lost processes leave readable history and a new-task action, without replacement |
The existing Composer owns text and attachment drafts. One executor/model picker presents executors
in its left rail and the browsed executor's models in its main pane. Browsing never changes the draft;
choosing a model commits the executor and model together. The Maka pane embeds main's native model
component, so contributed executors and native models share one boundary without duplicating
selection state. Main's client model-selection extension slot remains intact. Existing tasks keep their executor; model changes require idle state,
confirmation from the Agent, and a successful Session configuration write. Unsupported attachments
and native operations produce validation instead of changing or discarding the user's input.
External task naming uses a message-derived title; native recap generation is unavailable and does
not call an unrelated model or drain the Host.
The generic contract consists of `ExecutorCatalogEntry`, `ExecutorConfiguration`, the
`plugin.executor.query` Host operation, and provider discovery/inspection/configuration methods.
Session storage persists `executorId`, generic configuration and the compatible main model field.
The Host canonicalizes `executorModel` and `executorConfig.model`, rejects contradictions and
unavailable models, and pins catalog-managed executor configuration even when the caller omits it. External ACP Session identity,
process handles, credentials, and continuity markers stay inside the Plugin.
The model-selection controller now lives in the existing Conversation feature so the Desktop
architecture ownership/budget checks remain satisfied. Root exports preserve existing consumers.
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
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.
+1
View File
@@ -36,6 +36,7 @@ The root `DESIGN.md` owns shared product design intent. `docs/frontend-css-gover
## Validation snapshots
- `antigravity-acp-pr2-acceptance.md` — PR #5224's point-in-time controlled-process and real-Agent acceptance record; current behavior is defined by source, tests, and the Plugin READMEs.
- `desktop-smoke-plan-legacy.md` — completed PR acceptance paths and deferred UI designs removed from the current executable smoke runbook.
## Former repository notes
@@ -0,0 +1,293 @@
<!--
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.
-->
# Antigravity ACP PR 2 acceptance
Implementation scope follows the four PR 2 sets in [issue #5103](https://github.com/apache/maka/issues/5103).
No PR 3 restore or PR 4 mode/catalog lifecycle behavior is implied by these results.
## Official Agent environment
- macOS arm64; official `agy_acp_server` **1.1.1** and its adjacent `localharness_external`.
- Official archive: `https://dl.google.com/agy-extensions/releases/macos/agy-acp-server-agy_acp_server_1.1.1-darwin-arm64.zip`.
- Archive SHA-256: `fdfa915652cdb7ba8085cc8fffed072cbe009251aa2c951aabdda07a8c28a189`.
- Run dates: 2026-09-21–22, Asia/Shanghai.
- Existing authenticated Google session was used successfully. Fresh interactive Google sign-in is
PR 1 evidence and was **not repeated** in this run.
- HTTP(S) proxy environment variables were supplied for this machine's network. No credentials or
private project files were included in fixtures or evidence.
## Production Plugin acceptance
Loaded the built ACP runtime and Antigravity adapter through the production Plugin platform and
built-in external-agent coordinator, with Plugin storage bound to a temporary Host data root.
The official Agent performed these checks:
1. Authenticated model discovery; subsequent discovery reused the cached catalog.
2. First prompt used the explicit `gemini-3.8-flash-high` configuration, confirmed by inspection.
3. Created and tested fixture code; emitted text, thinking, tool updates and three authoritative
file diffs. Permission and structured-question forms preserved the Agent's option identities;
selecting beta returned option ID `2`.
4. A follow-up recalled a synthetic token; an independent task recalled its own different token.
5. An idle model change was confirmed by the Agent.
6. Cancellation settled the active prompt.
7. After closing/recreating the platform with the same Plugin data, inspection returned
`history_only`; execution refused to create a replacement external Session.
## Cross-process resume/load feasibility probe
An earlier prerequisite probe on 2026-09-12 used the official macOS arm64 ACP 1.1.1
server/helper, ACP SDK 1.4.0, cached authentication, and a temporary toy project. The
Agent created a Session, edited two fixture files, and the independent fixture test exited 0.
`initialize` advertised `agentCapabilities.loadSession: true` and
`agentCapabilities.sessionCapabilities.resume`.
The probe then terminated the original process tree. It started a **separate fresh process**
for each request, initialized and authenticated it using the cached login, and sent the
original Session ID and the same fixture directory to `session/resume` and `session/load`:
| Request | Observed result |
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `session/resume` | Succeeded and returned the Session's model/configuration; one `available_commands_update` notification arrived, with no transcript replay observed. |
| `session/load` | Succeeded and returned the Session's model/configuration; 27 replay notifications arrived: one user message chunk, one agent message chunk, 12 tool calls, 12 tool-call updates, and one available-commands update. |
All three owned process groups were gone after cleanup. This summary omits the
Session ID, credentials, and fixture content. The probe establishes that both methods accepted
the previously created Session across process restarts and shows that `load` replays
history. It does **not** establish that a subsequent prompt retains full context, how to
deduplicate replay against Maka's durable events, or how to reconcile a crash between an
Agent update and Maka's persistence. PR 3 must verify those behaviors before enabling
restoration; PR 2 continues to show such tasks as `history_only`.
## Desktop acceptance
Launched the built Electron app against an isolated temporary profile and a fixture project.
The isolation harness used the **real Host execution path**, not the fake native-model backend;
`MAKA_CU_REAL_MODEL_E2E` only selected the isolated profile and bounded native computer-use policy.
The external executor used the official binary and production Plugin bundles throughout.
This Desktop sequence was repeated on 2026-09-22 before the subsequent review fixes
documented below. The latest screenshot pass rechecked model selection without rerunning a coding task.
- Selected Antigravity, browsed all 11 models and selected Gemini 3.8 Flash (High). The review
follow-up consolidated executor browsing and model selection into one browse-then-commit panel;
the native Maka model control remains embedded in that same boundary.
- Created a task through the UI. The Agent wrote `add.js` and `add.test.js` and ran Node tests.
An independent `node --test add.test.js` check also passed (1/1).
- Approved tool permissions and answered the structured alpha/beta question with beta through
Hosted Forms. The completed transcript recorded the selected answer.
- The rebuilt Desktop task returned `PR2_UI_DONE`; its follow-up recalled `UI_ALPHA_5224` and
the selected `beta` fixture name correctly. The fixture test independently passed (1/1).
- Changed the model while idle to Gemini 3.7 Flash (High); the menu displayed the confirmed value.
- Started a long response, pressed Stop, and observed cancelled/interrupted settlement.
- Stopped the isolated Host and relaunched Desktop using the same isolated profile. The transcript remained readable;
the Composer showed process-lost/history-only guidance and a New Task action.
This run found and fixed an integration defect: automatic task naming tried to resolve an executor
as a native LLM connection, which drained the Host and cancelled the prompt. External naming now
uses the first message; unsupported native auxiliary calls fail as configuration errors. Naming and
recap regression tests protect that boundary.
## Earlier rebuild and full acceptance verification
The earlier 2026-09-22 rebuild uses main commit `e6db756890c36a8d4396241cc4f3a6f180529d20`.
Merge conflicts were resolved against main's executor-specific model/reasoning fields and client
model-selection extension slot. Compatibility epoch **179** follows main's epoch 176; PR 2's
three incompatible protocol steps occupy epochs 177–179.
Native model selection, thinking defaults and the distinction between untouched (`undefined`) and
explicit provider defaults (`null`) remain intact.
The official production Plugin acceptance above was **repeated after this rebuild** with official
ACP 1.1.1. All eleven checks passed, including model confirmation, file edits/tests, original
permission/question option identities, multi-turn context, independent tasks, idle model changes,
cancellation and restart history-only behavior. Existing Google authentication was reused.
Local validation at that acceptance checkpoint:
- Clean test build, production renderer build and every workspace type check passed.
- All 13 workspace suites passed: **12,883 Node tests passed, 38 skipped, zero failed**.
Python: **75 passed, 12 skipped** (87 total). Workspaces ran sequentially.
- After adding the final empty-config/model compatibility edge cases, the complete Host Session
catalog suite passed again (**67 tests**). The preceding targeted ACP, executor service/backend,
durable-event mapping and catalog run passed **145 tests**.
- Renderer architecture passed against the exact main base; lint, formatting, Desktop/UI Knip,
shell hook, locale, ASF header and Windows inventory gates passed.
- Release checks passed (**203 tests**, plus stale-output, notice and metadata checks).
Regression coverage verifies both model input forms, matching/contradictory values, an empty
configuration with an explicit model, unknown models before persistence, model pinning, and
cancellation racing `end_turn`, `max_tokens`, `refusal`, request failure or process crash. The
provider stop reason crosses the service/backend boundary and is stored in the runtime ledger;
timeouts and crashes remain interruption states. Controlled stdio tests also exercise filesystem
and symlink containment, retained identity, helper cleanup and history-only refusal.
## Subsequent review fixes and regression verification
The review found and fixed three execution regressions:
- Selecting a native model through the Composer fallback now clears the external executor.
The stored native model is not marked as selected while an external executor is active, so
choosing that same native model also switches back to Maka.
- ACP tool names and titles are sanitized and bounded before projection. Tool lifecycle flags
advance only after successful event emission, preserving visible tool history.
- A failed or aborted initialization before an external Session exists releases its task state
and permits retry. Loss of an established Session still remains history-only.
The failing CI protocol declaration referenced epoch 178 after the PR advanced to 179; it now
references 179. Local CI-equivalent checks also found raw controls in the executor picker:
they now use Astryx Button/TextInput, and the surface inventory is regenerated. The executor rail
and selected-model trigger omit the plug icon.
Regression verification after these fixes:
- Clean test build; Runtime **3,514 passed / 14 skipped**, Runtime Host **2,026 / 12**,
Desktop **2,809**, UI **625**, ACP executor **35**, Antigravity adapter **4**:
**9,013 passed, 26 skipped, zero failed**. The final layout change reran the complete UI and Desktop suites.
- Native-selection, initialization-retry and long/control-character tool metadata tests reproduce
the failures before the corresponding fixes.
- Production renderer build, stale-output check, workspace type checks, Desktop/UI Knip,
lint/format, ASF headers, locales, shell hooks and Windows inventory passed.
- Exact-base protocol epoch and renderer architecture checks passed, together with their
**17** and **112** checker tests; the Astryx inventory and its **19** tests passed.
## Unified model and thinking selection
Maka retains main's connection groups, provider icons and single-line native model names.
Gemini icons remain present; Antigravity has no plug icon or synthetic Agent default option.
Thinking intensity is selected exclusively beside the model trigger in the composer footer.
Native models still use their native thinking parameter. External models use the exact opaque
variant ID returned by ACP; the generic UI consumes structured catalog capabilities and has
no Gemini naming rules.
The Antigravity adapter recognizes only the verified Gemini Flash/Pro `(High|Medium|Low)`
label shape. A family with at least two unambiguous levels appears as one base-model row.
Selecting that row automatically selects its **highest supported level**, regardless of the
previous model's intensity. Afterwards the footer permits choosing only actual supported levels.
The external model replaces the native model trigger; there is no pending model caption,
extra native model, Cancel action, invented default, or invented off level. Unknown, singleton
and ambiguous families keep their original model rows without a fabricated thinking control.
Merely browsing or reopening the panel does not change configuration.
For existing sessions, UI state advances only after the Host confirms the requested model ID.
A failed or unconfirmed request restores the previous real ID and requires an ACP acknowledgement
before allowing retry. If rollback cannot be confirmed, or the process is lost, the existing
history-only restriction remains. Selection and prompt execution cannot overlap. Catalog updates,
scope changes, reopening a menu, and retained sessions use the same confirmed model mapping.
New-task selection validates a real ID against the ready catalog; ACP applies and confirms that
ID when the first prompt creates the session.
### Official ACP and Desktop verification
The official 1.1.1 server returned 11 model variants: Gemini 3.8, 3.7 and 3.6 Flash each with
Low/Medium/High, plus Gemini 3.1 Pro with Low/High. These form four base-model rows.
Its initial current model was `gemini-3.7-flash-high`; no per-family default was supplied.
Actual `session/set_config_option` responses confirmed `gemini-3.8-flash-medium`,
`gemini-pro-agent` (Pro High), and `gemini-3.1-pro-low`. No IDs were synthesized.
The separate ACP mode option was not interpreted as thinking strength.
On 2026-09-22 the rebuilt Electron renderer was checked with an isolated fixture profile:
- Native provider icons, single-line rows, search, and composer thinking remained intact.
- Selecting Flash replaced the native model with Antigravity / Flash and automatically chose High.
Its footer listed only Low/Medium/High; a manual change to Medium survived reopening the list.
- Switching to Pro automatically selected High and listed only Low/High in the footer.
- A real Pro High task returned `THINKING_UI_OK`, with `gemini-pro-agent` in the transcript.
While running, model selection was disabled. An idle footer change to Low was acknowledged;
the session summary then carried `gemini-3.1-pro-low` and the footer displayed Low.
- No native prompt or full coding task was repeated in this screenshot pass.
### Regression verification
Affected suites passed: Core **887**, Runtime **3,514 / 14 skipped**, Runtime Host
**2,027 / 12 skipped**, Desktop **2,809**, UI **633**, ACP executor **37**, and Antigravity
adapter **9**: **9,916 passed, 26 skipped, zero failed**.
Coverage includes complete/partial levels, unsorted capabilities, opaque IDs, unknown and
ambiguous families, highest-level selection, failed confirmation and rollback/retry, idle
catalog notifications, same-model verification, session scope races, execution locks, native
model reselection and executor switching.
Production renderer build, Desktop type checks, renderer architecture, Desktop/UI Knip,
lint/format, ASF headers, locale hygiene, Astryx inventory and stale-output checks passed.
Structured model-group/provider capabilities advance the protocol to epoch **180** at this
independent implementation checkpoint. The browser-safe declaration is pinned to the same epoch.
The four failing checks on `def615305` all reported an unresolved `modelChoiceDescription`
in the synthetic merge with main: main removed the wheel helper import, while the PR's new
panel still referenced it. Integration with current main removes this obsolete helper dependency
altogether. The panel searches the catalog's existing description/cutoff fields directly;
main's native row and wheel formatting remain unchanged. The delivery merge also preserves
main's epoch-177 external-session workspace change and advances the combined protocol to
**181**, with the browser-safe compatibility declaration pinned to 181.
## Delivery integration with current main
Integrated main at `0052f1cfd517abb0d3fab6cefa6f80a592b55589`, preserving its native model
formatting and external-session workspace protocol. The combined epoch is **181**.
The model-panel import failure is resolved in this actual merged source, not just on the PR head.
A clean test build, production renderer build, all workspace type checks, renderer architecture
against that main commit, Desktop/UI Knip, lint/format, ASF/locale checks, Astryx and Windows
inventories, shell-hook checks and the 17 protocol checker tests passed.
All 13 workspace suites passed after rechecks: **12,995 Node tests passed, 38 skipped**.
The first complete run had two intermittent failures (owned-candidate shutdown timing and a
context-offload lease assertion). Both focused checks and both complete affected suites passed
again without source changes: Host **2,033 / 12 skipped**, Storage **1,411 / 8 skipped**.
Release checks passed **203 tests**, plus their prerequisite metadata/notice/stale-output gates,
when run serially after the suite workload. The earlier concurrent release run hit a Node test
worker deserialization error and a real-Host shutdown timeout; these did not recur serially.
## Catalog-loading extensibility follow-up
The Host appends setup placeholders from its built-in external-agent catalog only when a
registered Plugin has not supplied that executor ID. The composer no longer names Antigravity
while loading; the shared picker displays a generic loading status until catalog data arrives.
A live Plugin catalog replaces the placeholder without a UI-specific fallback. Targeted Host
and picker tests cover placeholder precedence and the loading-to-ready transition. Storybook
smoke covers the loading and ready states. The PR description uses the full-window real Electron
captures below for UI evidence; the loading state is not pictured there.
## Earlier real Electron UI screenshots
Captured on 2026-09-22 from the production renderer in a real Electron Desktop window with the
official ACP 1.1.1 catalog. Screenshots are GitHub attachments only; no binaries are committed.
The native connection is an isolated fixture.
### Base models with Gemini icons
![Four Antigravity base models with Gemini icons](https://github.com/user-attachments/assets/359c589b-8500-4524-b396-faf35449a24d)
### Flash thinking in the composer
![Only the external model and actual Flash thinking levels in the footer](https://github.com/user-attachments/assets/94704904-c034-45d2-9e59-21f7b3025be2)
### Pro thinking in the composer
![Pro offers only its supported Low and High levels](https://github.com/user-attachments/assets/53defac2-e25a-4101-b12a-325e631b717c)
### Maka native model format
![Native model rows retain main formatting and footer thinking](https://github.com/user-attachments/assets/9074a33d-3fb3-42ae-8efc-24186b0f3c72)
The earlier completed-task capture remains
[historical execution evidence](https://github.com/user-attachments/assets/4fc2827b-feed-4639-9a6a-d50caf203055),
not a claim about the current picker or a repeated coding run.
Public CI, independent human approval and merge remain repository gates. This document does not
mark issue #5103's PR 2 checkbox complete. Unknown future label shapes remain raw models until
verified. Cross-process restoration belongs to PR 3; modes and expanded catalog lifecycle belong
to PR 4. Neither is claimed by this PR.
+3 -1
View File
@@ -6,7 +6,7 @@ Generated against `@astryxdesign/core@0.6.2` (195 component exports).
Wiki bar: Design Conventions · API Use-the-System · Theming · Container Padding.
**Totals:** 293 files — blocker 0, reimplementation 0, polish 4, aligned 289.
**Totals:** 295 files — blocker 0, reimplementation 0, polish 4, aligned 291.
## Exclusions (explicit)
@@ -271,6 +271,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/composer.tsx` | shell-chrome-or-panel | Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, IconButton, Lightbox, Token, Tooltip | aligned — uses Astryx (Button, ChatComposer, ChatComposerDrawer, ChatComposerInput, DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuDivider, DropdownMenuItem) | aligned |
| `packages/ui/src/daily-review-panel.tsx` | module-hub | Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem, SegmentedControl, SegmentedControlItem, Skeleton, StackItem, Text, Toolbar, VStack | aligned — uses Astryx (Banner, Button, Divider, EmptyState, HStack, Heading, List, ListItem) | aligned |
| `packages/ui/src/directory-reference-chip.tsx` | ui-composition | Token, Tooltip | aligned — uses Astryx (Token, Tooltip) | aligned |
| `packages/ui/src/executor-model-picker.tsx` | ui-composition | Button, Popover | aligned — uses Astryx (Button, Popover) | aligned |
| `packages/ui/src/form-interaction-prompt.tsx` | ui-composition | Button, CheckboxInput, RadioList, RadioListItem, Selector, Text, TextInput | aligned — uses Astryx (Button, CheckboxInput, RadioList, RadioListItem, Selector, Text, TextInput) | aligned |
| `packages/ui/src/icons.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/inline-reference.tsx` | ui-composition | ChatTokenizedText | aligned — uses Astryx (ChatTokenizedText) | aligned |
@@ -282,6 +283,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi
| `packages/ui/src/markdown.tsx` | ui-composition | none | aligned — no raw controls; no Astryx JSX usage | aligned |
| `packages/ui/src/mermaid-diagram.tsx` | ui-composition | Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar | aligned — uses Astryx (Button, CodeBlock, Collapsible, Dialog, IconButton, Toolbar) | aligned |
| `packages/ui/src/model-picker-internals.tsx` | ui-composition | SelectorOption | aligned — uses Astryx (SelectorOption) | aligned |
| `packages/ui/src/model-picker-panel.tsx` | shell-chrome-or-panel | Button, TextInput | aligned — uses Astryx (Button, TextInput) | aligned |
| `packages/ui/src/model-picker.tsx` | ui-composition | Selector | aligned — uses Astryx (Selector) | aligned |
| `packages/ui/src/model-wheel-picker.tsx` | ui-composition | Button | aligned — uses Astryx (Button) | aligned |
| `packages/ui/src/module-hub-selector.tsx` | ui-composition | Tab, TabList | aligned — uses Astryx (Tab, TabList) | aligned |
+2
View File
@@ -242,6 +242,7 @@ packages/ui/src/composer-message-queue.tsx
packages/ui/src/composer.tsx
packages/ui/src/daily-review-panel.tsx
packages/ui/src/directory-reference-chip.tsx
packages/ui/src/executor-model-picker.tsx
packages/ui/src/form-interaction-prompt.tsx
packages/ui/src/icons.tsx
packages/ui/src/inline-reference.tsx
@@ -253,6 +254,7 @@ packages/ui/src/markdown-math.tsx
packages/ui/src/markdown.tsx
packages/ui/src/mermaid-diagram.tsx
packages/ui/src/model-picker-internals.tsx
packages/ui/src/model-picker-panel.tsx
packages/ui/src/model-picker.tsx
packages/ui/src/model-wheel-picker.tsx
packages/ui/src/module-hub-selector.tsx
@@ -96,7 +96,7 @@ Full metadata remains available to the main process and lazy-loaded SettingsModa
Acceptance criteria:
- The startup entry and all of its static transitive dependencies exclude `model-metadata.generated.ts`, `model-metadata.ts`, `provider-registry.ts`, `model-catalog.ts`, and `model-thinking.ts`.
- The startup path no longer statically depends on the renderer's `model-catalog-choices.ts`; `shell-chat-model-selection.ts` deliberately remains on the static path as the lightweight selector behind `useShellChatModel` — this optimization removes the heavy metadata modules, not that selector.
- The startup path no longer statically depends on the renderer's `model-catalog-choices.ts`; the conversation feature's `model/shell-chat-model-selection.ts` deliberately remains on the static path as the lightweight selector behind `useShellChatModel` — this optimization removes the heavy metadata modules, not that selector.
- Searching startup chunks for `claude-opus|gpt-5\.|gemini-2\.` returns zero; full metadata exists only on lazy Settings paths.
- Model choices, headings, provider logos, and active/new-chat thinking levels remain correct.
- OnboardingHero still shows the four recommended providers with their names, descriptions, and logos.
@@ -171,7 +171,7 @@ Session health notice 在 event 触发的异步刷新完成前继续使用上一
验收标准:
- 首屏入口及其所有静态传递依赖不包含 `model-metadata.generated.ts`、`model-metadata.ts`、`provider-registry.ts`、`model-catalog.ts` 或 `model-thinking.ts`。
- 首屏不再静态依赖 renderer 的 `model-catalog-choices.ts`;`shell-chat-model-selection.ts` 作为 `useShellChatModel` 背后的轻量选择器有意保留在静态路径上——本优化移除的是重量级元数据模块,不含这个选择器。
- 首屏不再静态依赖 renderer 的 `model-catalog-choices.ts`;conversation feature 下的 `model/shell-chat-model-selection.ts` 作为 `useShellChatModel` 背后的轻量选择器有意保留在静态路径上——本优化移除的是重量级元数据模块,不含这个选择器。
- 构建产物的首屏 chunk 中检索 `claude-opus|gpt-5\.|gemini-2\.` 为 0;完整元数据只存在于设置页懒加载路径。
- model picker 的模型、heading、provider logo,以及 active/new-chat thinking level 选项保持正确。
- OnboardingHero 正常显示 4 个推荐 provider 的名称、描述和 logo。
+4 -2
View File
@@ -16,10 +16,10 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t
| Classification | Count |
|---|---:|
| windows-backend-gap | 27 |
| portable-candidate | 35 |
| portable-candidate | 37 |
| platform-contract | 38 |
Total Windows-excluded declarations: **100**
Total Windows-excluded declarations: **102**
## Inventory
@@ -37,6 +37,8 @@ Total Windows-excluded declarations: **100**
| platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` |
| platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` bounds shell output instead of buffering until the global timeout | `process.platform === 'win32'` |
| platform-contract | `apps/desktop/src/main/__tests__/skill-locations.test.ts` reports an unreadable Skill directory instead of an available empty location | `process.platform === 'win32' ? 'POSIX permissions are required to make the Skill directory unreadable' : process.getuid?.() === 0` |
| portable-candidate | `packages/acp-executor-plugin/src/__tests__/acp-process.test.ts` disposing a real retained process also terminates its helper | `process.platform === 'win32'` |
| portable-candidate | `packages/acp-executor-plugin/src/__tests__/acp-process.test.ts` retiring one conversation cleans its helper after the parent has crashed | `process.platform === 'win32'` |
| platform-contract | `packages/cli/src/__tests__/acp-prompt-content.test.ts` rejects a FIFO without blocking the process | `process.platform === 'win32'` |
| portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` shortens POSIX paths under the home directory | `process.platform === 'win32'` |
| portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` keeps POSIX paths outside the home directory absolute | `process.platform === 'win32'` |
+37
View File
@@ -14,6 +14,8 @@
"packages/storage",
"packages/mcp",
"packages/runtime",
"packages/acp-executor-plugin",
"packages/antigravity-acp-plugin",
"packages/runtime-host",
"packages/eval",
"packages/computer-use",
@@ -3401,6 +3403,14 @@
"ws": "^8.19.0"
}
},
"node_modules/@maka/acp-executor-plugin": {
"resolved": "packages/acp-executor-plugin",
"link": true
},
"node_modules/@maka/antigravity-acp-plugin": {
"resolved": "packages/antigravity-acp-plugin",
"link": true
},
"node_modules/@maka/computer-use": {
"resolved": "packages/computer-use",
"link": true
@@ -17064,6 +17074,31 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"packages/acp-executor-plugin": {
"name": "@maka/acp-executor-plugin",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0"
},
"devDependencies": {
"@maka/core": "0.1.0",
"@maka/runtime": "0.1.0"
}
},
"packages/antigravity-acp-plugin": {
"name": "@maka/antigravity-acp-plugin",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@maka/acp-executor-plugin": "0.1.0"
},
"devDependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@maka/runtime": "0.1.0",
"@maka/core": "0.1.0"
}
},
"packages/cli": {
"name": "maka-agent",
"version": "0.2.0",
@@ -17207,6 +17242,8 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@maka/acp-executor-plugin": "0.1.0",
"@maka/antigravity-acp-plugin": "0.1.0",
"@maka/core": "0.1.0",
"@maka/runtime": "0.1.0",
"@maka/storage": "0.1.0",
+4 -2
View File
@@ -18,6 +18,8 @@
"packages/storage",
"packages/mcp",
"packages/runtime",
"packages/acp-executor-plugin",
"packages/antigravity-acp-plugin",
"packages/runtime-host",
"packages/eval",
"packages/computer-use",
@@ -46,8 +48,8 @@
"dev:full": "npm run build && npm --workspace @maka/desktop run start",
"dev:full:peer": "npm run build && npm --workspace @maka/desktop run start:peer",
"cli:dev": "node packages/cli/dist/dev-cli.js",
"build": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build",
"build:test": "npm run clean && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test",
"build": "npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build",
"build:test": "npm run clean && npm --workspace @maka/core run build && npm --workspace @maka/storage run build && npm --workspace @maka/mcp run build && npm --workspace @maka/runtime run build && npm --workspace @maka/acp-executor-plugin run build && npm --workspace @maka/antigravity-acp-plugin run build && npm --workspace @maka/runtime-host run build && npm --workspace @maka/computer-use run build && npm --workspace @maka/eval run build && npm --workspace maka-agent run build && npm --workspace @maka/ui run build && npm --workspace @maka/desktop run build:test",
"clean": "node scripts/clean-build.mjs",
"rebuild": "npm run clean && npm run build",
"check:stale": "node scripts/check-stale-dist.mjs",
+33
View File
@@ -0,0 +1,33 @@
<!--
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.
-->
# ACP Executor Runtime Plugin
This package is the shared ACP transport and lifecycle layer. It provides `ctx.acp` to child Plugin
Entries. An external Agent package supplies only an `AcpAgentAdapter`; the service turns that adapter
into the generic executor contribution introduced by #5283.
Installing this package creates the `acp-runtime` profile Entry. Keep adapter Entries below it so the
service follows normal Plugin Context inheritance. The Host remains unaware of ACP and sees only
`ctx.executors` registrations.
Adapter packages register with `ctx.acp.register(ctx, adapter, config)`. Passing the adapter Entry's
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.
@@ -0,0 +1,24 @@
# 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.
- type: insert
rootId: profile
entry:
id: acp-runtime
packageId: acp-executor
isolate:
acp: true
@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"id": "acp-executor",
"displayName": "ACP Executor Runtime",
"description": "Shared ACP protocol and process runtime for external Agent adapter plugins.",
"runtime": {
"entry": "dist/plugin.mjs"
},
"composition": {
"patch": "maka.composition.yml",
"structuralDependencies": []
}
}
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@maka/acp-executor-plugin",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Shared ACP runtime service for Maka executor adapter plugins.",
"type": "module",
"private": true,
"files": [
"dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./plugin": "./dist/plugin.mjs"
},
"scripts": {
"clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo",
"build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:dist": "node --test \"dist/**/*.test.js\""
},
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0"
},
"devDependencies": {
"@maka/runtime": "0.1.0",
"@maka/core": "0.1.0"
}
}
@@ -0,0 +1,982 @@
/*
* 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, mkdtemp, rm, 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 type { PluginExecutorContext } from '@maka/runtime/plugin-executor-service';
import {
AcpExecutor,
type AcpAgentAdapter,
type AcpConnectionFactory,
type AcpConversationStateStore,
} from '../index.js';
const adapter: AcpAgentAdapter<{ executable: string; model?: string }> = {
id: 'fixture-acp',
displayName: 'Fixture',
configure: (config) => ({
launch: {
executable: config.executable,
...(config.model ? { initialConfig: { model: config.model } } : {}),
},
}),
};
test('runtime retains one ACP process and Session across prompts', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable, model: 'fast' },
{
createConnection: protocol.factory,
},
);
const events: unknown[] = [];
const context = executorContext(events);
try {
assert.deepEqual(await executor.execute(request('first'), context), {
status: 'completed',
text: 'reply:first',
});
assert.deepEqual(await executor.execute(request('second'), context), {
status: 'completed',
text: 'reply:second',
});
assert.equal(protocol.connections, 1);
assert.equal(protocol.sessions, 1);
assert.equal(protocol.prompts, 2);
assert.equal(protocol.selectedModel, 'fast');
assert.equal(
(
events.find((event) => (event as { type: string }).type === 'tool_result') as {
content: { kind: string; paths: string[]; diff: string };
}
).content.kind,
'file_diff',
);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
assert.equal(protocol.disposals, 1);
});
test('a terminal ACP tool update retains both output text and its file diff', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
},
);
const events: Array<{ type: string; text?: string; content?: { kind: string } }> = [];
try {
assert.equal(
(await executor.execute(request('mixed'), executorContext(events))).status,
'completed',
);
assert.equal(events.find((event) => event.type === 'tool_output_delta')?.text, '1 test failed');
assert.equal(events.find((event) => event.type === 'tool_result')?.content?.kind, 'file_diff');
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('a mixed terminal update emits only text not already streamed', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
},
);
const events: Array<{ type: string; text?: string }> = [];
try {
assert.equal(
(await executor.execute(request('mixed-progress'), executorContext(events))).status,
'completed',
);
assert.deepEqual(
events.filter((event) => event.type === 'tool_output_delta').map((event) => event.text),
['Running tests…', '\n1 test failed'],
);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('runtime rejects a historical conversation after process continuity was lost', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const marked = new Set<string>();
const state: AcpConversationStateStore = {
has: async (key, cwd) => marked.has(`${key}\0${cwd}`),
mark: async (key, cwd) => {
marked.add(`${key}\0${cwd}`);
},
};
const first = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state,
},
);
try {
assert.equal((await first.execute(request('first'), executorContext([]))).status, 'completed');
await first.dispose();
const restarted = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state,
},
);
try {
assert.deepEqual(await restarted.execute(request('second'), executorContext([])), {
status: 'failed',
message: 'ACP conversation is history-only after the Plugin or Host was restarted',
code: 'acp_history_only',
recoverable: false,
});
assert.equal(protocol.connections, 1);
} finally {
await restarted.dispose();
}
} finally {
await first.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('a continuity write failure cannot start an ACP Session and remains retryable', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
let writes = 0;
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state: {
has: async () => false,
mark: async () => {
if (++writes === 1) throw new Error('Storage unavailable');
},
},
},
);
try {
assert.equal((await executor.execute(request('first'), executorContext([]))).status, 'failed');
assert.equal(protocol.sessions, 0, 'no Agent Session was created before the durable write');
assert.equal(
(await executor.execute(request('retry'), executorContext([]))).status,
'completed',
);
assert.equal(protocol.sessions, 1);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('an uncertain session/new response remains history-only after restart', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
protocol.sessionCreationFailure = 'once';
const marked = new Set<string>();
const state: AcpConversationStateStore = {
has: async (key) => marked.has(key),
mark: async (key) => {
marked.add(key);
},
};
const createExecutor = () =>
new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state,
},
);
const first = createExecutor();
try {
assert.equal((await first.execute(request('first'), executorContext([]))).status, 'failed');
assert.equal(marked.has('session-a'), true);
assert.equal(protocol.sessions, 1);
await first.dispose();
const restarted = createExecutor();
try {
assert.equal(
(await restarted.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
.readiness,
'history_only',
);
assert.equal(
(await restarted.execute(request('retry'), executorContext([]))).status,
'failed',
);
assert.equal(protocol.sessions, 1, 'the uncertain external Session was not replaced');
} finally {
await restarted.dispose();
}
} finally {
await first.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('a failed first model change cannot replace an established ACP Session after restart', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
protocol.configurationFailure = 'once';
const marked = new Set<string>();
const state: AcpConversationStateStore = {
has: async (key) => marked.has(key),
mark: async (key) => {
marked.add(key);
},
};
const createExecutor = () =>
new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state,
},
);
const first = createExecutor();
try {
const input = { ...request('first'), configuration: { model: 'fast' } };
assert.equal((await first.execute(input, executorContext([]))).status, 'failed');
assert.equal(protocol.sessions, 1);
assert.equal(protocol.prompts, 0);
assert.equal(marked.has(input.conversationKey), true);
await first.dispose();
const restarted = createExecutor();
try {
const result = await restarted.execute(input, executorContext([]));
assert.equal(result.status, 'failed');
if (result.status === 'failed') assert.equal(result.code, 'acp_history_only');
assert.equal(protocol.sessions, 1, 'the established ACP Session was not replaced');
} finally {
await restarted.dispose();
}
} finally {
await first.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
for (const startupFailure of ['request', 'crash', 'abort'] as const) {
test(`a pre-session ${startupFailure} failure is cleaned up and can be retried`, async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const abort = new AbortController();
let attempts = 0;
let disposals = 0;
let marks = 0;
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
state: {
has: async () => false,
mark: async () => {
marks++;
},
},
createConnection: (input) => {
if (++attempts > 1) return protocol.factory(input);
input.configureClient(chainableApp());
let crash!: (error: Error) => void;
const failed = new Promise<never>((_resolve, reject) => {
crash = reject;
});
return {
failed,
dispose: async () => {
disposals++;
},
connection: {
agent: {
request: async (method: string) => {
assert.equal(method, methods.agent.initialize);
if (startupFailure === 'abort') {
abort.abort(new DOMException('Stopped during startup', 'AbortError'));
throw abort.signal.reason;
}
if (startupFailure === 'crash') {
crash(new Error('Process exited before initialization'));
return await new Promise(() => {});
}
throw new Error('Temporary initialization failure');
},
},
} as unknown as ClientConnection,
};
},
},
);
try {
const first = await executor.execute(request('first'), executorContext([], abort.signal));
assert.equal(first.status, startupFailure === 'abort' ? 'cancelled' : 'failed');
assert.equal(disposals, 1);
assert.equal(marks, 0, 'no external session was established');
assert.equal(
(await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
.readiness,
'ready',
);
assert.equal(
(await executor.execute(request('retry'), executorContext([]))).status,
'completed',
);
assert.equal(attempts, 2);
assert.equal(protocol.sessions, 1);
assert.equal(protocol.prompts, 1);
assert.equal(marks, 1);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
}
for (const stopReason of [
'cancelled',
'end_turn',
'max_tokens',
'refusal',
'request_error',
'process_crash',
]) {
test(`runtime drains cancellation and preserves ${stopReason}`, async () => {
const fixture = await executableFixture();
let started!: () => void;
const ready = new Promise<void>((resolve) => {
started = resolve;
});
let settle!: () => void;
const settled = new Promise<void>((resolve) => {
settle = resolve;
});
let cancellations = 0;
let crash!: (error: Error) => void;
const failed = new Promise<never>((_resolve, reject) => {
crash = reject;
});
const factory: AcpConnectionFactory = (input) => {
input.configureClient(chainableApp());
return {
connection: {
agent: {
request: async (method: string) => {
if (method === methods.agent.initialize) return { protocolVersion: 1 };
if (method === methods.agent.session.new) return { sessionId: 'acp-session' };
if (method === methods.agent.session.prompt) {
started();
await settled;
if (stopReason === 'request_error') throw new Error('request failed');
return { stopReason };
}
throw new Error(`Unexpected ACP method: ${method}`);
},
notify: async () => {
cancellations += 1;
if (stopReason === 'process_crash') crash(new Error('process exited'));
else settle();
},
},
close: () => undefined,
} as unknown as ClientConnection,
failed,
dispose: async () => undefined,
};
};
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{ createConnection: factory },
);
const abort = new AbortController();
const execution = executor.execute(request('cancel'), executorContext([], abort.signal));
await ready;
abort.abort(new Error('user_stop'));
try {
assert.deepEqual(await execution, {
status: 'cancelled',
...(['request_error', 'process_crash'].includes(stopReason)
? { reason: 'crash', providerStopReason: 'acp_execution_failed' }
: { providerStopReason: stopReason }),
});
assert.equal(cancellations, 1);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
}
for (const selection of [
{ model: 'fast' },
{ configuration: { model: 'fast' } },
{ model: 'fast', configuration: { model: 'fast' } },
{ model: 'fixture-acp', configuration: {} },
{ model: 'removed' },
{ model: 'default', configuration: { model: 'fast' } },
]) {
test(`runtime consumes or rejects the exact model selection ${JSON.stringify(selection)}`, async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{ createConnection: protocol.factory },
);
try {
const result = await executor.execute(
{ ...request('model'), ...selection },
executorContext([]),
);
const invalid = selection.model === 'removed' || selection.model === 'default';
assert.equal(result.status, invalid ? 'failed' : 'completed');
assert.equal(protocol.prompts, invalid ? 0 : 1);
if (!invalid && selection.model !== 'fixture-acp')
assert.equal(protocol.selectedModel, 'fast');
if (invalid && result.status === 'failed') assert.equal(result.code, 'acp_config_invalid');
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
}
test('discovery shares a disposable probe, does not mark a task, and first prompt applies the task model', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
let marks = 0;
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{
createConnection: protocol.factory,
state: {
has: async () => false,
mark: async () => {
marks++;
},
},
},
);
try {
const [a, b] = await Promise.all([
executor.discover({ cwd: fixture.root, signal: new AbortController().signal }),
executor.discover({ cwd: fixture.root, signal: new AbortController().signal }),
]);
assert.deepEqual(a, b);
assert.equal(a.readiness, 'ready');
assert.equal(a.currentModel, 'default');
assert.deepEqual(
a.models.map((model) => model.id),
['default', 'fast'],
);
assert.equal(protocol.connections, 1);
assert.equal(protocol.disposals, 1);
assert.equal(marks, 0);
assert.equal(protocol.prompts, 0);
assert.equal(
(
await executor.execute(
{ ...request('selected'), configuration: { model: 'fast' } },
executorContext([]),
)
).status,
'completed',
);
assert.equal(protocol.selectedModel, 'fast');
assert.equal(marks, 1);
assert.equal(protocol.connections, 2);
const before = protocol.connections;
const status = await executor.inspectConversation({
conversationKey: 'session-a',
cwd: fixture.root,
});
assert.equal(status.currentModel, 'fast');
assert.equal(protocol.connections, before);
await executor.configureConversation(
{ conversationKey: 'session-a', cwd: fixture.root, configuration: { model: 'default' } },
new AbortController().signal,
);
assert.equal(protocol.selectedModel, 'default');
await assert.rejects(
() =>
executor.configureConversation(
{ conversationKey: 'session-a', cwd: fixture.root, configuration: { model: 'removed' } },
new AbortController().signal,
),
/unavailable/u,
);
assert.equal(protocol.selectedModel, 'default');
assert.equal(
(await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
.readiness,
'ready',
);
assert.equal(
(await executor.execute(request('after-invalid-model'), executorContext([]))).status,
'completed',
);
} 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();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{ createConnection: protocol.factory },
);
const abort = new AbortController();
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
const selectedRequest = { ...request('first'), configuration: { model: 'default' } };
assert.equal(
(await executor.execute(selectedRequest, executorContext([]))).status,
'completed',
);
protocol.configurationFailure = failure;
timeout = setTimeout(() => abort.abort(new DOMException('Timed out', 'TimeoutError')), 50);
await assert.rejects(
executor.configureConversation(
{ conversationKey: 'session-a', cwd: fixture.root, configuration: { model: 'fast' } },
abort.signal,
),
);
// The external mutation happened even though no matching confirmation arrived.
assert.equal(protocol.selectedModel, failure === 'timeout' ? 'fast' : 'default');
assert.equal(
(await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
.readiness,
'history_only',
);
assert.equal((await executor.execute(selectedRequest, executorContext([]))).status, 'failed');
assert.deepEqual(protocol.promptModels, ['default']);
assert.equal(protocol.connections, 1);
assert.equal(protocol.disposals, 1);
} finally {
clearTimeout(timeout);
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
}
test('questions retain option identity and output updates retain arrival order', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
{ ...adapter, permissionKind: () => 'question' },
{ executable: fixture.executable },
{ createConnection: protocol.factory },
);
const events: unknown[] = [];
try {
const result = await executor.execute(request('question'), {
...executorContext(events),
requestPermission: async (request) => {
assert.equal(request.kind, 'question');
assert.equal(request.options[0]?.optionId, 'allow_once');
events.push({ type: 'question' });
return { outcome: 'selected', optionId: 'allow_once' };
},
});
assert.equal(result.status, 'completed');
assert.deepEqual(
events.map((event) => (event as { type: string }).type),
[
'question',
'output_delta',
'tool_start',
'tool_output_delta',
'tool_output_delta',
'tool_result',
],
);
assert.deepEqual(
events
.filter((event) => (event as { type: string }).type === 'tool_output_delta')
.map((event) => (event as { text: string }).text),
['Running', ' tests…'],
);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
function request(text: string) {
return {
sessionId: 'session-a',
turnId: `turn-${text}`,
conversationKey: 'session-a',
cwd: process.cwd(),
text,
};
}
function executorContext(
events: unknown[],
signal = new AbortController().signal,
): PluginExecutorContext {
return {
signal,
emit: (event) => events.push(event),
requestPermission: async (request) => {
assert.equal(request.title, 'Allow edit?');
return { outcome: 'selected', optionId: 'allow_once' };
},
};
}
async function executableFixture() {
const root = await mkdtemp(join(tmpdir(), 'maka-acp-runtime-'));
const executable = join(root, 'agent');
await writeFile(executable, 'fixture');
await chmod(executable, 0o700);
return { root, executable };
}
function chainableApp() {
const app = {
onNotification() {
return app;
},
onRequest() {
return app;
},
};
return app as unknown as ClientApp;
}
function fakeProtocol(): {
readonly factory: AcpConnectionFactory;
connections: number;
sessions: number;
prompts: number;
disposals: number;
selectedModel?: string;
configurationFailure?: 'response_lost' | 'unconfirmed' | 'timeout' | 'once';
sessionCreationFailure?: 'once';
promptModels: string[];
notifyConfiguration(model: string): void;
} {
const fixture = {
connections: 0,
sessions: 0,
prompts: 0,
disposals: 0,
selectedModel: undefined as string | undefined,
configurationFailure: undefined as
| 'response_lost'
| 'unconfirmed'
| 'timeout'
| 'once'
| undefined,
sessionCreationFailure: undefined as 'once' | undefined,
promptModels: [] as string[],
notifyConfiguration: (_model: string): void => {},
factory: undefined as unknown as AcpConnectionFactory,
};
fixture.factory = (input) => {
fixture.connections += 1;
const notifications = new Map<string, (input: { params: never }) => unknown>();
const requests = new Map<string, (input: { params: never }) => unknown>();
const app = {
onNotification(method: string, handler: (input: { params: never }) => unknown) {
notifications.set(method, handler);
return app;
},
onRequest(method: string, handler: (input: { params: never }) => unknown) {
requests.set(method, handler);
return app;
},
} as unknown as ClientApp;
input.configureClient(app);
fixture.notifyConfiguration = (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' },
],
},
],
},
} as never,
});
};
const connection = {
agent: {
request: async (
method: string,
params: Record<string, unknown>,
options?: { cancellationSignal?: AbortSignal },
) => {
if (method === methods.agent.initialize) return { protocolVersion: 1 };
if (method === methods.agent.session.new) {
fixture.sessions += 1;
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' },
],
},
],
};
}
if (method === methods.agent.session.setConfigOption) {
fixture.selectedModel = String(params.value);
if (fixture.configurationFailure === 'once') {
fixture.configurationFailure = undefined;
throw new Error('Transient rejection after mutation');
}
if (fixture.configurationFailure === 'response_lost')
throw new Error('Configuration response lost after applying the model');
if (fixture.configurationFailure === 'timeout') {
const signal = options!.cancellationSignal!;
signal.throwIfAborted();
await new Promise((_, reject) =>
signal.addEventListener('abort', () => reject(signal.reason), { once: true }),
);
}
return {
configOptions: [
{
type: 'select',
id: 'model',
name: 'Model',
currentValue:
fixture.configurationFailure === 'unconfirmed' ? 'default' : params.value,
options: [
{ value: 'default', name: 'Default' },
{ value: 'fast', name: 'Fast' },
],
},
],
};
}
if (method === methods.agent.session.prompt) {
fixture.prompts += 1;
fixture.promptModels.push(fixture.selectedModel ?? 'default');
const text = (params.prompt as Array<{ text: string }>)[0]!.text;
await requests.get(methods.client.session.requestPermission)?.({
params: {
sessionId: 'acp-session',
toolCall: { toolCallId: `tool-${text}`, title: 'Allow edit?' },
options: [{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }],
} as never,
});
notifications.get(methods.client.session.update)?.({
params: {
sessionId: 'acp-session',
update: {
sessionUpdate: 'agent_message_chunk',
content: { type: 'text', text: `reply:${text}` },
},
} as never,
});
notifications.get(methods.client.session.update)?.({
params: {
sessionId: 'acp-session',
update: {
sessionUpdate: 'tool_call',
toolCallId: `tool-${text}`,
title: 'Edit file',
kind: 'edit',
status: 'in_progress',
...(text === 'question'
? {
content: [{ type: 'content', content: { type: 'text', text: 'Running' } }],
}
: {}),
},
} as never,
});
if (text === 'question' || text === 'mixed-progress') {
for (const output of ['Running tests…', 'Running tests…'])
notifications.get(methods.client.session.update)?.({
params: {
sessionId: 'acp-session',
update: {
sessionUpdate: 'tool_call_update',
toolCallId: `tool-${text}`,
status: 'in_progress',
content: [{ type: 'content', content: { type: 'text', text: output } }],
},
} as never,
});
}
notifications.get(methods.client.session.update)?.({
params: {
sessionId: 'acp-session',
update: {
sessionUpdate: 'tool_call_update',
toolCallId: `tool-${text}`,
status: 'completed',
content: [
{ type: 'diff', path: 'README.md', oldText: 'old', newText: 'new' },
...(text === 'mixed' || text === 'mixed-progress'
? [
{
type: 'content',
content: {
type: 'text',
text:
text === 'mixed'
? '1 test failed'
: 'Running tests…\n1 test failed',
},
},
]
: []),
],
},
} as never,
});
return { stopReason: 'end_turn' };
}
throw new Error(`Unexpected ACP method: ${method}`);
},
notify: async () => undefined,
},
close: () => undefined,
} as unknown as ClientConnection;
return {
connection,
failed: new Promise<never>(() => undefined),
dispose: async () => {
fixture.disposals += 1;
},
};
};
return fixture;
}
for (const failure of ['once', 'unconfirmed'] as const)
test(`confirmed rollback preserves the previous model and permits retry after ${failure}`, async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{ createConnection: protocol.factory },
);
const input = {
conversationKey: 'session-a',
cwd: fixture.root,
configuration: { model: 'fast' },
};
try {
await executor.execute(
{ ...request('first'), configuration: { model: 'default' } },
executorContext([]),
);
protocol.configurationFailure = failure;
await assert.rejects(executor.configureConversation(input, new AbortController().signal));
assert.equal(protocol.selectedModel, 'default');
const state = await executor.inspectConversation(input);
assert.equal(state.currentModel, 'default');
assert.equal(state.readiness, 'ready');
protocol.configurationFailure = undefined;
await executor.configureConversation(input, new AbortController().signal);
assert.equal((await executor.inspectConversation(input)).currentModel, 'fast');
assert.equal(protocol.disposals, 0);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
test('idle Agent configuration notifications update the inspected model without another prompt', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
{ createConnection: protocol.factory },
);
try {
await executor.execute(
{ ...request('first'), configuration: { model: 'default' } },
executorContext([]),
);
protocol.notifyConfiguration('fast');
assert.equal(
(await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
.currentModel,
'fast',
);
assert.equal(protocol.prompts, 1);
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
}
});
@@ -0,0 +1,403 @@
/*
* 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 { test } from 'node:test';
import { mkdtemp, writeFile, readFile, rm, symlink } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AcpExecutor, type AcpAgentAdapter } from '../index.js';
import { readWorkspaceTextFile } from '../acp-filesystem.js';
import { Context } from '@maka/runtime/plugin-kernel';
import { PluginExecutorService } from '@maka/runtime/plugin-executor-service';
import { PluginExecutorBackend } from '@maka/runtime/plugin-executor-backend';
import type {
PluginExecutorContext,
PluginExecutorOutputEvent,
} from '@maka/runtime/plugin-executor-service';
// This fixture exercises the actual SDK, stdio transport, OS processes and callbacks.
const program = String.raw`
const {createInterface}=require('node:readline');
const {spawn}=require('node:child_process');
const pending=new Map(); let seq=0, turn=0, cwd, promptId;
const send=(value)=>process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n');
const respond=(id,result)=>send({id,result});
const update=(update)=>send({method:'session/update',params:{sessionId:'fixture',update}});
const text=(text)=>update({sessionUpdate:'agent_message_chunk',content:{type:'text',text}});
const call=(method,params)=>new Promise(resolve=>{const id='client-'+(++seq);pending.set(id,resolve);send({id,method,params});});
createInterface({input:process.stdin}).on('line',async line=>{
const m=JSON.parse(line);
if(pending.has(m.id)){pending.get(m.id)(m);pending.delete(m.id);return;}
if(m.method==='initialize')return respond(m.id,{protocolVersion:1});
if(m.method==='session/new'){cwd=m.params.cwd;return respond(m.id,{sessionId:'fixture',configOptions:[]});}
if(m.method==='session/cancel'){if(promptId!==undefined){respond(promptId,{stopReason:'cancelled'});promptId=undefined;}return;}
if(m.method!=='session/prompt')return;
const value=m.params.prompt[0].text;
if(value==='crash'){process.exit(3);return;}
if(value==='ignore-cancel'){text('waiting');return;}
if(value==='wait'){promptId=m.id;text('waiting');return;}
if(value==='helper'){const child=spawn(process.execPath,['-e','setInterval(()=>{},1000)'],{stdio:'ignore'});text(String(child.pid));respond(m.id,{stopReason:'end_turn'});return;}
if(value.startsWith('write:')){
const result=await call('fs/write_text_file',{sessionId:'fixture',...JSON.parse(value.slice(6))});
text(JSON.stringify(result));
}else if(value.startsWith('metadata:')){
const metadata=JSON.parse(value.slice(9));
update({sessionUpdate:'tool_call',toolCallId:'metadata-tool',title:metadata.title,name:metadata.name,kind:'edit',status:'in_progress'});
update({sessionUpdate:'tool_call_update',toolCallId:'metadata-tool',status:'completed',content:[{type:'content',content:{type:'text',text:'edit completed'}}]});
}else if(value.startsWith('diff:')){
const newline=JSON.parse(value.slice(5));
const content='new'+newline;
await call('fs/write_text_file',{sessionId:'fixture',path:cwd+'/edited.txt',content});
update({sessionUpdate:'tool_call',toolCallId:'edit',title:'Edit file',kind:'edit',status:'in_progress'});
update({sessionUpdate:'tool_call_update',toolCallId:'edit',status:'completed',content:[{type:'diff',path:'edited.txt',oldText:'old'+newline,newText:content}]});
}else if(value==='files'){
const write=await call('fs/write_text_file',{sessionId:'fixture',path:cwd+'/created.txt',content:'fixture'});
const read=await call('fs/read_text_file',{sessionId:'fixture',path:cwd+'/created.txt'});
const escape=await call('fs/read_text_file',{sessionId:'fixture',path:cwd+'/escape.txt'});
text(JSON.stringify({write:!!write.result,read:read.result?.content,escape:!!escape.error}));
}else{
const result=await call('session/request_permission',{sessionId:'fixture',toolCall:{toolCallId:'interaction_fixture',title:'Alpha or beta?'},options:[{optionId:'alpha-id',name:'Alpha',kind:'allow_once'},{optionId:'beta-id',name:'Beta',kind:'allow_once'}]});
text(JSON.stringify({pid:process.pid,turn:++turn,choice:result.result.outcome.optionId}));
}
respond(m.id,{stopReason:'end_turn'});
});
`;
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'maka-acp-process-'));
const script = join(root, 'agent.cjs');
await writeFile(script, program);
const adapter: AcpAgentAdapter = {
id: 'stdio-fixture',
displayName: 'Fixture',
configure: () => ({ launch: { executable: process.execPath, args: [script] } }),
permissionKind: () => 'question',
};
const marked = new Set<string>();
const executor = new AcpExecutor(
adapter,
{},
{
state: {
has: async (key) => marked.has(key),
mark: async (key) => {
marked.add(key);
},
},
},
);
const request = (text: string) => ({
sessionId: 'task',
conversationKey: 'task',
turnId: text,
text,
cwd: root,
});
const context = (
signal = new AbortController().signal,
emit: (event: PluginExecutorOutputEvent) => void = () => {},
): PluginExecutorContext => ({
signal,
emit,
requestPermission: async (request) => {
assert.equal(request.kind, 'question');
return { outcome: 'selected', optionId: 'beta-id' };
},
});
return {
root,
executor,
request,
context,
dispose: async () => {
await executor.dispose();
await rm(root, { recursive: true, force: true });
},
};
}
test('file read rejects invalid ranges and honors a zero limit', async () => {
const cwd = await mkdtemp(join(tmpdir(), 'maka-acp-read-range-'));
try {
const path = join(cwd, 'lines.txt');
await writeFile(path, 'first\nsecond\nthird');
await assert.rejects(() => readWorkspaceTextFile(cwd, path, -3), /positive integer/u);
await assert.rejects(() => readWorkspaceTextFile(cwd, path, 0), /positive integer/u);
await assert.rejects(() => readWorkspaceTextFile(cwd, path, 1, -1), /non-negative integer/u);
assert.equal(await readWorkspaceTextFile(cwd, path, 2, 1), 'second');
assert.equal(await readWorkspaceTextFile(cwd, path, undefined, 0), '');
} finally {
await rm(cwd, { recursive: true, force: true });
}
});
test('real stdio retains a conversation and returns original question option ids', async () => {
const f = await fixture();
try {
const first = await f.executor.execute(f.request('one'), f.context());
const second = await f.executor.execute(f.request('two'), f.context());
assert.equal(first.status, 'completed');
assert.equal(second.status, 'completed');
if (first.status !== 'completed' || second.status !== 'completed')
throw new Error('Expected completion');
const a = JSON.parse(first.text),
b = JSON.parse(second.text);
assert.equal(a.pid, b.pid);
assert.equal(b.turn, 2);
assert.equal(b.choice, 'beta-id');
} finally {
await f.dispose();
}
});
test('real filesystem callbacks reject a symlink escape and permit workspace files', async () => {
const f = await fixture();
const outside = await mkdtemp(join(tmpdir(), 'maka-acp-outside-'));
try {
await writeFile(join(outside, 'secret.txt'), 'outside');
await symlink(join(outside, 'secret.txt'), join(f.root, 'escape.txt'));
const result = await f.executor.execute(f.request('files'), f.context());
assert.equal(result.status, 'completed');
if (result.status !== 'completed') throw new Error('Expected completion');
assert.deepEqual(JSON.parse(result.text), { write: true, read: 'fixture', escape: true });
assert.equal(await readFile(join(f.root, 'created.txt'), 'utf8'), 'fixture');
} finally {
await f.dispose();
await rm(outside, { recursive: true, force: true });
}
});
test('file writes reject existing and dangling links outside the workspace', async () => {
const f = await fixture();
const outside = await mkdtemp(join(tmpdir(), 'maka-acp-outside-'));
try {
const existing = join(outside, 'existing.txt');
const missing = join(outside, 'missing.txt');
await writeFile(existing, 'unchanged');
await symlink(existing, join(f.root, 'existing-link.txt'));
await symlink(missing, join(f.root, 'dangling-link.txt'));
await symlink('dangling-link.txt', join(f.root, 'indirect-link.txt'));
for (const name of ['existing-link.txt', 'dangling-link.txt', 'indirect-link.txt']) {
const result = await f.executor.execute(
f.request(
`write:${JSON.stringify({ path: join(f.root, name), content: 'must not escape' })}`,
),
f.context(),
);
assert.equal(result.status, 'completed');
if (result.status !== 'completed') throw new Error('Expected completion');
assert.ok(JSON.parse(result.text).error, name);
assert.equal(await readFile(existing, 'utf8'), 'unchanged');
await assert.rejects(readFile(missing), { code: 'ENOENT' });
}
// Refusing a file callback must not lose the retained conversation.
assert.equal((await f.executor.execute(f.request('after'), f.context())).status, 'completed');
} finally {
await f.dispose();
await rm(outside, { recursive: true, force: true });
}
});
test('file writes create and truncate regular files but reject links inside the workspace', async () => {
const f = await fixture();
try {
const target = join(f.root, 'target.txt');
const write = async (path: string, content: string) => {
const result = await f.executor.execute(
f.request(`write:${JSON.stringify({ path, content })}`),
f.context(),
);
if (result.status !== 'completed') throw new Error('Expected completion');
assert.deepEqual(JSON.parse(result.text).result, {});
};
await write(target, 'long initial content');
assert.equal(await readFile(target, 'utf8'), 'long initial content');
await write(target, 'short');
assert.equal(await readFile(target, 'utf8'), 'short');
const link = join(f.root, 'internal-link.txt');
await symlink(target, link);
const result = await f.executor.execute(
f.request(`write:${JSON.stringify({ path: link, content: 'via link' })}`),
f.context(),
);
if (result.status !== 'completed') throw new Error('Expected completion');
assert.ok(JSON.parse(result.text).error);
assert.equal(await readFile(target, 'utf8'), 'short');
} finally {
await f.dispose();
}
});
for (const metadata of [
{ title: 'Long title '.repeat(1000), name: 'edit'.repeat(100) },
{ title: 'Edit\0file\r\nnow', name: 'edit\0\r\nfile' },
{ title: 'Edit file', name: '\0\r\n' },
]) {
test(`real tool metadata is bounded without losing activity (${metadata.name.length} characters)`, async () => {
const f = await fixture();
const root = new Context();
const executors = new PluginExecutorService(root);
root
.extend({
maka: { rootId: 'profile', packageId: 'fixture', entryId: 'stdio', generation: 1 },
})
.executors.register(f.executor);
const backend = new PluginExecutorBackend({
sessionId: 'task',
cwd: f.root,
binding: executors.bind('task', f.executor.id),
});
try {
const events = [];
for await (const event of backend.send({
turnId: 'metadata',
text: `metadata:${JSON.stringify(metadata)}`,
}))
events.push(event);
const start = events.find((event) => event.type === 'tool_start');
const result = events.find((event) => event.type === 'tool_result');
assert.ok(start && start.type === 'tool_start', 'tool activity must reach the transcript');
assert.ok(result && result.type === 'tool_result');
assert.equal(result.toolUseId, start.toolUseId);
assert.notEqual(result.isError, true);
assert.deepEqual(result.content, { kind: 'text', text: 'edit completed' });
assert.equal(events.at(-1)?.type, 'complete');
} finally {
await backend.dispose();
await root.fiber.dispose();
await f.dispose();
}
});
}
for (const newline of ['\n', '\r\n']) {
test(`real ${JSON.stringify(newline)} file diffs survive executor validation and backend projection`, async () => {
const f = await fixture();
const root = new Context();
const executors = new PluginExecutorService(root);
root
.extend({
maka: { rootId: 'profile', packageId: 'fixture', entryId: 'stdio', generation: 1 },
})
.executors.register(f.executor);
const backend = new PluginExecutorBackend({
sessionId: 'task',
cwd: f.root,
binding: executors.bind('task', f.executor.id),
});
try {
const events = [];
for await (const event of backend.send({
turnId: 'edit',
text: `diff:${JSON.stringify(newline)}`,
}))
events.push(event);
const result = events.find((event) => event.type === 'tool_result');
assert.ok(result && result.type === 'tool_result');
assert.notEqual(result.isError, true);
assert.deepEqual(result.content, {
kind: 'file_diff',
paths: ['edited.txt'],
diff: '--- a/edited.txt\n+++ b/edited.txt\n@@ -1,1 +1,1 @@\n-old\n+new\n',
});
assert.equal(events.at(-1)?.type, 'complete');
assert.equal(await readFile(join(f.root, 'edited.txt'), 'utf8'), `new${newline}`);
} finally {
await backend.dispose();
await root.fiber.dispose();
await f.dispose();
}
});
}
test('real cancellation settles before follow-up; crash makes the task history-only', async () => {
const f = await fixture();
try {
const abort = new AbortController();
const result = await f.executor.execute(
f.request('wait'),
f.context(abort.signal, () => abort.abort()),
);
assert.equal(result.status, 'cancelled');
assert.equal((await f.executor.execute(f.request('after'), f.context())).status, 'completed');
assert.equal((await f.executor.execute(f.request('crash'), f.context())).status, 'failed');
assert.equal(
(await f.executor.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
'history_only',
);
assert.equal(
(await f.executor.execute(f.request('never-replay'), f.context())).status,
'failed',
);
} finally {
await f.dispose();
}
});
test('disposing a real retained process also terminates its helper', {
skip: process.platform === 'win32',
}, async () => {
const f = await fixture();
try {
const result = await f.executor.execute(f.request('helper'), f.context());
assert.equal(result.status, 'completed');
if (result.status !== 'completed') throw new Error('Expected completion');
const pid = Number(result.text);
assert.ok(pid > 0);
await f.executor.dispose();
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
} finally {
await f.dispose();
}
});
test('an unresponsive cancel is bounded, records timeout and loses the process', async () => {
const f = await fixture();
try {
const abort = new AbortController();
const result = await f.executor.execute(
f.request('ignore-cancel'),
f.context(abort.signal, () => abort.abort()),
);
assert.deepEqual(result, { status: 'cancelled', reason: 'timeout' });
assert.equal(
(await f.executor.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
'history_only',
);
} finally {
await f.dispose();
}
});
test('retiring one conversation cleans its helper after the parent has crashed', {
skip: process.platform === 'win32',
}, async () => {
const f = await fixture();
try {
const result = await f.executor.execute(f.request('helper'), f.context());
if (result.status !== 'completed') throw new Error('Expected helper');
const pid = Number(result.text);
await f.executor.execute(f.request('crash'), f.context());
await f.executor.disposeConversation('task');
assert.throws(() => process.kill(pid, 0), { code: 'ESRCH' });
} finally {
await f.dispose();
}
});
@@ -0,0 +1,51 @@
/*
* 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 { test } from 'node:test';
import { projectToolResult } from '../acp-projection.js';
for (const { name, oldText, newText, expected } of [
{
name: 'newline-terminated files',
oldText: 'a\n',
newText: 'b\n',
expected: '--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-a\n+b\n',
},
{
name: 'files without a final newline',
oldText: 'a',
newText: 'b',
expected:
'--- a/file.txt\n+++ b/file.txt\n@@ -1,1 +1,1 @@\n-a\n\\ No newline at end of file\n+b\n\\ No newline at end of file\n',
},
{
name: 'an empty old file',
oldText: '',
newText: 'b\n',
expected: '--- a/file.txt\n+++ b/file.txt\n@@ -0,0 +1,1 @@\n+b\n',
},
]) {
test(`ACP file diff represents ${name}`, () => {
assert.deepEqual(
projectToolResult([{ type: 'diff', path: 'file.txt', oldText, newText }], undefined),
{ kind: 'file_diff', paths: ['file.txt'], diff: expected },
);
});
}
@@ -0,0 +1,28 @@
/*
* 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.
*/
export class AcpRuntimeError extends Error {
constructor(
message: string,
readonly code: string,
) {
super(message);
this.name = 'AcpRuntimeError';
}
}
@@ -0,0 +1,117 @@
/*
* 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 { lstat, open, realpath, stat, type FileHandle } from 'node:fs/promises';
import { dirname, isAbsolute, relative, resolve } from 'node:path';
const MAX_TEXT_FILE_BYTES = 8 * 1024 * 1024;
export async function readWorkspaceTextFile(
cwd: string,
path: string,
line?: number | null,
limit?: number | null,
): Promise<string> {
if (line != null && (!Number.isSafeInteger(line) || line < 1))
throw new Error('ACP file line must be a positive integer');
if (limit != null && (!Number.isSafeInteger(limit) || limit < 0))
throw new Error('ACP file limit must be a non-negative integer');
const file = await openWorkspaceFile(cwd, path, false);
try {
const info = await file.stat();
if (info.size > MAX_TEXT_FILE_BYTES) throw new Error('ACP text file is too large');
const text = await file.readFile('utf8');
const start = line == null ? 0 : line - 1;
return line != null || limit != null
? text
.split('\n')
.slice(start, limit == null ? undefined : start + limit)
.join('\n')
: text;
} finally {
await file.close();
}
}
export async function writeWorkspaceTextFile(
cwd: string,
path: string,
content: string,
): Promise<void> {
if (Buffer.byteLength(content) > MAX_TEXT_FILE_BYTES)
throw new Error('ACP text file is too large');
const file = await openWorkspaceFile(cwd, path, true);
try {
// Validation happens against this exact open file descriptor before any
// existing content is truncated, so later path swaps cannot redirect data.
await file.truncate(0);
await file.writeFile(content, 'utf8');
} finally {
await file.close();
}
}
async function openWorkspaceFile(
cwd: string,
path: string,
forWrite: boolean,
): Promise<FileHandle> {
if (!isAbsolute(path)) throw new Error('ACP file path must be absolute');
const root = await realpath(cwd);
const candidate = resolve(path);
if (forWrite) {
const parent = await realpath(dirname(candidate));
assertContained(root, parent);
const entry = await lstat(candidate).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
return undefined;
});
if (entry?.isSymbolicLink()) throw new Error('ACP file path could not be resolved');
}
const flags = forWrite
? constants.O_WRONLY | constants.O_CREAT | (constants.O_NOFOLLOW ?? 0)
: constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0);
const file = await open(candidate, flags, 0o600);
try {
const opened = await file.stat();
if (!opened.isFile()) throw new Error('ACP file path is not a regular file');
// Resolve and compare after opening. If the path or an ancestor changed
// between lookup and open, either containment or file identity no longer
// matches. Operations after this point use only the verified descriptor.
const canonical = await realpath(candidate);
assertContained(root, canonical);
const current = await stat(canonical);
if (opened.dev !== current.dev || opened.ino !== current.ino)
throw new Error('ACP file path changed during validation');
return file;
} catch (error) {
await file.close();
throw error;
}
}
function assertContained(root: string, candidate: string): void {
const relation = relative(root, candidate);
if (relation === '..' || relation.startsWith('../') || isAbsolute(relation))
throw new Error('ACP file path leaves the workspace');
}
@@ -0,0 +1,145 @@
/*
* 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 { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
import { Readable, Writable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import {
client,
ndJsonStream,
type ClientApp,
type ClientConnection,
} from '@agentclientprotocol/sdk';
import { terminateProcessTree } from '@maka/runtime/process-tree-terminator';
import { AcpRuntimeError } from './acp-errors.js';
const PROCESS_EXIT_TIMEOUT_MS = 2_000;
export interface AcpConnectionInput {
readonly executable: string;
readonly args?: readonly string[];
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly clientName: string;
readonly configureClient: (app: ClientApp) => void;
}
export interface AcpConnectionOwner {
readonly connection: ClientConnection;
readonly failed: Promise<never>;
dispose(): Promise<void>;
}
export function createAcpConnection(input: AcpConnectionInput): AcpConnectionOwner {
let child: ChildProcessWithoutNullStreams;
try {
child = spawn(input.executable, [...(input.args ?? [])], {
cwd: input.cwd,
env: input.env,
stdio: 'pipe',
detached: true,
shell: false,
});
} catch {
throw new AcpRuntimeError('ACP executable is unavailable', 'acp_executable_unavailable');
}
let disposing = false;
let disposed = false;
let disposal: Promise<void> | undefined;
let rejectFailure!: (error: Error) => void;
const failed = new Promise<never>((_resolve, reject) => {
rejectFailure = reject;
});
void failed.catch(() => undefined);
const fail = () => {
if (!disposing) rejectFailure(new Error('ACP connection failed'));
};
child.once('error', fail);
child.stdin.once('error', fail);
child.stderr.once('error', fail);
child.stderr.on('data', () => undefined);
child.once('close', fail);
const app = client({ name: input.clientName });
input.configureClient(app);
const connection = app.connect(
ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
),
);
void connection.closed.then(fail, fail);
return {
connection,
failed,
dispose() {
if (disposed) return Promise.resolve();
if (disposal) return disposal;
disposing = true;
disposal = terminate(child, connection).then(
() => {
disposed = true;
},
(error) => {
disposal = undefined;
throw error;
},
);
return disposal;
},
};
}
async function terminate(
child: ChildProcessWithoutNullStreams,
connection: ClientConnection,
): Promise<void> {
const pid = child.pid;
const alive = () => {
if (!pid) return false;
if (process.platform === 'win32') return child.exitCode === null && child.signalCode === null;
try {
process.kill(-pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
};
try {
if (pid) {
await terminateProcessTree({ pid, signal: 'SIGTERM', fallback: () => child.kill('SIGTERM') });
for (let elapsed = 0; elapsed < PROCESS_EXIT_TIMEOUT_MS && alive(); elapsed += 50)
await delay(50);
if (alive()) {
await terminateProcessTree({
pid,
signal: 'SIGKILL',
fallback: () => child.kill('SIGKILL'),
});
for (let elapsed = 0; elapsed < PROCESS_EXIT_TIMEOUT_MS && alive(); elapsed += 50)
await delay(50);
}
if (alive()) throw new Error('ACP process cleanup failed');
}
} finally {
connection.close();
child.stdin.destroy();
child.stdout.destroy();
child.stderr.destroy();
}
}
@@ -0,0 +1,168 @@
/*
* 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 { ToolCall, ToolCallContent } from '@agentclientprotocol/sdk';
import type {
PluginExecutorContext,
PluginExecutorRequest,
PluginExecutorToolResultContent,
} from '@maka/runtime/plugin-executor-service';
const MAX_EVENT_TEXT = 8_192;
const MAX_TOOL_RESULT_DIFF = 1024 * 1024;
export function promptText(request: Readonly<PluginExecutorRequest>): string {
const sections = [request.text];
if (request.instructions) sections.push(`Agent instructions:\n${request.instructions}`);
for (const quote of request.quotes ?? []) sections.push(`Quoted context:\n${quote.text}`);
for (const reference of request.directoryReferences ?? [])
sections.push(`Project directory reference: ${reference.path}`);
return sections.filter(Boolean).join('\n\n');
}
export function emitText(
context: PluginExecutorContext,
type: 'output_delta' | 'thinking_delta',
text: string,
): void {
const safeText = text.replaceAll('\r', '');
for (let offset = 0; offset < safeText.length; offset += MAX_EVENT_TEXT) {
const chunk = safeText.slice(offset, offset + MAX_EVENT_TEXT);
context.emit({ type, text: chunk });
}
}
export function emitToolOutput(
context: PluginExecutorContext,
toolCallId: string,
text: string,
): void {
const safeText = text.replaceAll('\r', '');
for (let offset = 0; offset < safeText.length; offset += MAX_EVENT_TEXT) {
context.emit({
type: 'tool_output_delta',
toolCallId,
text: safeText.slice(offset, offset + MAX_EVENT_TEXT),
});
}
}
export function projectToolResult(
content: readonly ToolCallContent[],
rawOutput: unknown,
): PluginExecutorToolResultContent {
const diffs = content.flatMap((item) =>
item.type === 'diff'
? [
{
path: item.path,
diff: createWholeFileDiff(item.path, item.oldText ?? '', item.newText ?? ''),
},
]
: [],
);
const combinedDiff = diffs.map(({ diff }) => diff).join('');
if (diffs.length && diffs.length <= 64 && combinedDiff.length <= MAX_TOOL_RESULT_DIFF)
return {
kind: 'file_diff',
paths: diffs.map(({ path }) => path),
diff: combinedDiff,
};
if (diffs.length)
return {
kind: 'text',
text: boundedText(
`${diffs.map(({ path }) => `Updated ${path}`).join('\n')}\nDiff omitted because it exceeds the executor event limit.`,
),
};
return { kind: 'text', text: boundedText(summarizeToolResult(content, rawOutput)) };
}
function createWholeFileDiff(path: string, oldText: string, newText: string): string {
const oldFile = diffLines(oldText);
const newFile = diffLines(newText);
const oldLines = oldFile.lines;
const newLines = newFile.lines;
return (
[
`--- a/${path}`,
`+++ b/${path}`,
`@@ -${oldLines.length ? 1 : 0},${oldLines.length} +${newLines.length ? 1 : 0},${newLines.length} @@`,
...diffSide(oldLines, '-', oldFile.endsWithNewline),
...diffSide(newLines, '+', newFile.endsWithNewline),
].join('\n') + '\n'
);
}
function diffLines(text: string): { lines: string[]; endsWithNewline: boolean } {
const normalized = text.replaceAll('\r', '');
if (!normalized) return { lines: [], endsWithNewline: true };
const endsWithNewline = normalized.endsWith('\n');
const lines = normalized.split('\n');
if (endsWithNewline) lines.pop();
return { lines, endsWithNewline };
}
function diffSide(lines: readonly string[], prefix: '-' | '+', endsWithNewline: boolean): string[] {
return lines.flatMap((line, index) => [
`${prefix}${line}`,
...(index === lines.length - 1 && !endsWithNewline ? ['\\ No newline at end of file'] : []),
]);
}
export function summarizeToolContent(content: readonly ToolCallContent[]): string {
return content
.map((item) => {
if (item.type === 'diff') return `Updated ${item.path}`;
if (item.type === 'terminal') return item.terminalId ? `Terminal ${item.terminalId}` : '';
return item.content.type === 'text' ? item.content.text : '';
})
.filter(Boolean)
.join('\n');
}
function summarizeToolResult(content: readonly ToolCallContent[], rawOutput: unknown): string {
const summary = summarizeToolContent(content);
if (summary) return summary;
if (rawOutput === undefined) return '';
try {
return JSON.stringify(rawOutput);
} catch {
return 'External tool completed';
}
}
export function boundedText(value: string): string {
const safe = value.replace(/[\0\r]/gu, '');
return safe.length <= MAX_EVENT_TEXT ? safe : `${safe.slice(0, MAX_EVENT_TEXT - 1)}…`;
}
export function toolName(value: string): string {
return value.replace(/[\0\r\n]/gu, '').slice(0, 256) || 'external_tool';
}
export function activityKind(kind: ToolCall['kind'] | undefined) {
if (kind === 'read') return 'read' as const;
if (kind === 'edit' || kind === 'delete' || kind === 'move') return 'edit' as const;
if (kind === 'search') return 'search' as const;
if (kind === 'fetch') return 'webfetch' as const;
if (kind === 'execute') return 'command' as const;
if (kind === 'think') return 'explore' as const;
return 'tool' as const;
}
+887
View File
@@ -0,0 +1,887 @@
/*
* 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 { createHash } from 'node:crypto';
import { constants } from 'node:fs';
import { access, mkdtemp, rm, realpath, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import type { ExecutorCatalogEntry, ExecutorConfiguration } from '@maka/core/executor-catalog';
import { dirname, isAbsolute, resolve } from 'node:path';
import {
methods,
type ClientApp,
type ClientConnection,
type RequestPermissionRequest,
type SessionConfigOption,
type SessionUpdate,
type ToolCall,
type ToolCallContent,
type ToolCallUpdate,
} from '@agentclientprotocol/sdk';
import type { PluginStorageService } from '@maka/runtime/plugin-data-services';
import type {
PluginExecutorContext,
PluginExecutorProvider,
PluginExecutorRequest,
PluginExecutorResult,
} from '@maka/runtime/plugin-executor-service';
import type { Context, Disposable } from '@maka/runtime/plugin-kernel';
import { AcpRuntimeError } from './acp-errors.js';
import { readWorkspaceTextFile, writeWorkspaceTextFile } from './acp-filesystem.js';
import { createAcpConnection, type AcpConnectionOwner } from './acp-process.js';
import {
activityKind,
boundedText,
emitText,
emitToolOutput,
projectToolResult,
promptText,
summarizeToolContent,
toolName,
} from './acp-projection.js';
declare module '@maka/runtime/plugin-kernel' {
interface Context {
readonly acp: AcpRuntimeService;
}
}
const CANCEL_TIMEOUT_MS = 15_000;
const INITIALIZE_TIMEOUT_MS = 30_000;
export interface AcpLaunchSpec {
readonly executable: string;
readonly args?: readonly string[];
/** Sidecar binaries the adapter requires before the ACP process may start. */
readonly requiredExecutables?: readonly string[];
readonly cwd?: string;
readonly env?: NodeJS.ProcessEnv;
readonly initialConfig?: Readonly<Record<string, string>>;
}
export interface AcpConfiguredAgent {
readonly launch: AcpLaunchSpec;
}
/** Product-specific code ends at this interface. */
export interface AcpAgentAdapter<TConfig = unknown> {
readonly id: string;
readonly displayName: string;
readonly clientName?: string;
configure(config: TConfig): AcpConfiguredAgent;
permissionKind?(request: RequestPermissionRequest): 'permission' | 'question';
executionFailed?(text: string): boolean;
describeModels?(
models: ExecutorCatalogEntry['models'],
): Pick<ExecutorCatalogEntry, 'models' | 'modelGroups'>;
}
export interface AcpConnectionFactoryInput extends AcpLaunchSpec {
readonly clientName: string;
readonly executable: string;
readonly cwd: string;
readonly env: NodeJS.ProcessEnv;
readonly configureClient: (app: ClientApp) => void;
}
export type AcpConnectionFactory = (input: AcpConnectionFactoryInput) => AcpConnectionOwner;
export interface AcpConversationStateStore {
has(conversationKey: string, cwd: string): Promise<boolean>;
mark(conversationKey: string, cwd: string): Promise<void>;
}
interface ActivePrompt {
readonly context: PluginExecutorContext;
readonly tools: Map<string, ToolSnapshot>;
text: string;
}
interface ToolSnapshot {
readonly id: string;
title: string;
name?: string;
kind?: ToolCall['kind'];
status?: ToolCall['status'];
content: ToolCallContent[];
rawInput?: unknown;
rawOutput?: unknown;
output: string;
outputText: string;
started: boolean;
terminal: boolean;
}
interface RetainedSession {
readonly conversationKey: string;
readonly configuration?: ExecutorConfiguration;
readonly cwd: string;
owner?: AcpConnectionOwner;
connection?: ClientConnection;
acpSessionId?: string;
continuityReserved?: boolean;
configOptions: readonly SessionConfigOption[];
initialization?: Promise<void>;
active?: ActivePrompt;
configuring?: boolean;
lost: boolean;
loss?: Promise<void>;
}
export class AcpExecutor implements PluginExecutorProvider {
readonly id: string;
readonly displayName: string;
readonly capabilities = Object.freeze({ thinking: true, toolActivity: true });
readonly #adapter: AcpAgentAdapter;
readonly #configured: AcpConfiguredAgent;
readonly #createConnection: AcpConnectionFactory;
readonly #state?: AcpConversationStateStore;
readonly #sessions = new Map<string, RetainedSession>();
#disposed = false;
#catalog?: ExecutorCatalogEntry;
#discovery?: Promise<ExecutorCatalogEntry>;
constructor(
adapter: AcpAgentAdapter,
config: unknown,
options: {
readonly createConnection?: AcpConnectionFactory;
readonly state?: AcpConversationStateStore;
} = {},
) {
this.#adapter = validateAdapter(adapter);
this.id = adapter.id;
this.displayName = adapter.displayName;
this.#configured = validateConfiguredAgent(adapter.configure(config));
this.#createConnection = options.createConnection ?? createAcpConnection;
this.#state = options.state;
}
async discover(input: { cwd: string; signal: AbortSignal }): Promise<ExecutorCatalogEntry> {
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 = {
conversationKey: 'catalog-probe',
cwd,
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;
}
async inspectConversation(input: {
conversationKey: string;
cwd: string;
configuration?: ExecutorConfiguration;
}): Promise<ExecutorCatalogEntry> {
const session = this.#sessions.get(input.conversationKey);
if (this.#disposed) return this.#catalogEntry('unavailable');
if (session?.lost || (!session && (await this.#state?.has(input.conversationKey, input.cwd))))
return this.#catalogEntry('history_only');
return this.#catalogEntry('ready', session?.configOptions ?? [], input.configuration?.model);
}
async configureConversation(
input: { conversationKey: string; cwd: string; configuration?: ExecutorConfiguration },
signal: AbortSignal,
): Promise<void> {
const session = this.#sessions.get(input.conversationKey);
if (
!session?.connection ||
session.lost ||
session.active ||
session.configuring ||
!input.configuration?.model
)
throw new Error('ACP model change is unavailable');
session.configuring = true;
try {
await this.#applyInitialConfig(session, { model: input.configuration.model }, signal, true);
} finally {
session.configuring = false;
}
}
#catalogEntry(
readiness: ExecutorCatalogEntry['readiness'],
options: readonly SessionConfigOption[] = [],
selected?: string,
): ExecutorCatalogEntry {
const model = options.find(
(option) =>
option.type === 'select' && (option.category === 'model' || option.id === 'model'),
);
const models =
model?.type === 'select'
? model.options
.flatMap((entry) => ('options' in entry ? entry.options : [entry]))
.map((entry) => ({ id: entry.value, name: entry.name }))
: [];
return {
id: this.id,
displayName: this.displayName,
readiness,
...(this.#adapter.describeModels?.(models) ?? { models }),
...(model?.type === 'select'
? { currentModel: model.currentValue }
: selected
? { currentModel: selected }
: {}),
supportsAttachments: false,
supportsModelChange: model?.type === 'select',
};
}
async execute(
request: Readonly<PluginExecutorRequest>,
context: PluginExecutorContext,
): Promise<PluginExecutorResult> {
if (this.#disposed) return failure('ACP executor is unavailable', 'acp_unavailable');
if (request.attachments?.length) {
return failure(
`${this.displayName} ACP supports project files and text only`,
'acp_attachments_unsupported',
);
}
if (
request.configuration?.model &&
request.model &&
request.model !== this.id &&
request.configuration.model !== request.model
)
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 } };
let session: RetainedSession;
try {
session = this.#session(request);
} catch (error) {
return failure(safeErrorMessage(error), errorCode(error));
}
if (session.active || session.configuring)
return failure(`${this.displayName} ACP Session is busy`, 'acp_busy', true);
const active: ActivePrompt = { context, tools: new Map(), text: '' };
session.active = active;
try {
await this.#ensureInitialized(session, context.signal);
context.signal.throwIfAborted();
if (request.configuration?.model)
await this.#applyInitialConfig(
session,
{ model: request.configuration.model },
context.signal,
);
const prompt = session.connection!.agent.request(methods.agent.session.prompt, {
sessionId: session.acpSessionId!,
prompt: [{ type: 'text', text: promptText(request) }],
});
const response = await this.#awaitPrompt(session, prompt, context.signal);
if (!response) return { status: 'cancelled', reason: 'timeout' };
if (context.signal.aborted || response.stopReason === 'cancelled') {
return { status: 'cancelled', providerStopReason: response.stopReason };
}
if (this.#adapter.executionFailed?.(active.text)) {
return failure(`${this.displayName} reported an execution failure`, 'acp_prompt_failed');
}
if (response.stopReason !== 'end_turn') {
return failure(
`${this.displayName} stopped before completing (${response.stopReason})`,
'acp_prompt_incomplete',
);
}
return { status: 'completed', text: active.text };
} catch (error) {
await this.#lose(session);
// Retry only when no session/new was attempted. Once continuity is
// reserved, a lost response may hide an established Agent Session.
const retryable =
!session.acpSessionId &&
!session.continuityReserved &&
errorCode(error) !== 'acp_history_only';
if (retryable && this.#sessions.get(session.conversationKey) === session)
this.#sessions.delete(session.conversationKey);
if (context.signal.aborted) {
if (
error === context.signal.reason ||
(error instanceof DOMException && error.name === 'AbortError')
)
return { status: 'cancelled' };
return { status: 'cancelled', reason: 'crash', providerStopReason: errorCode(error) };
}
return failure(safeErrorMessage(error), errorCode(error), retryable);
} finally {
if (session.active === active) session.active = undefined;
}
}
async disposeConversation(conversationKey: string): Promise<void> {
const session = this.#sessions.get(conversationKey);
if (session) await this.#lose(session);
}
async dispose(): Promise<void> {
if (this.#disposed) return;
this.#disposed = true;
const sessions = [...this.#sessions.values()];
this.#sessions.clear();
const settlements = await Promise.allSettled(
sessions.map((session) => session.loss ?? this.#disposeSession(session)),
);
const failures = settlements.flatMap((settlement) =>
settlement.status === 'rejected' ? [settlement.reason] : [],
);
if (failures.length) throw new AggregateError(failures, 'ACP process cleanup failed');
}
#session(request: Readonly<PluginExecutorRequest>): RetainedSession {
const cwd = resolve(request.cwd);
const existing = this.#sessions.get(request.conversationKey);
if (existing) {
if (existing.cwd !== cwd)
throw new AcpRuntimeError('ACP conversation cannot change workspace', 'acp_cwd_changed');
if (existing.lost)
throw new AcpRuntimeError(
'ACP conversation is history-only because its external process was lost',
'acp_history_only',
);
return existing;
}
const created: RetainedSession = {
conversationKey: request.conversationKey,
...(request.configuration ? { configuration: request.configuration } : {}),
cwd,
configOptions: [],
lost: false,
};
this.#sessions.set(request.conversationKey, created);
return created;
}
async #ensureInitialized(session: RetainedSession, signal: AbortSignal): Promise<void> {
if (session.initialization) return await session.initialization;
const initialization = this.#initialize(session, signal);
session.initialization = initialization;
try {
await initialization;
} catch (error) {
session.initialization = undefined;
throw error;
}
}
async #initialize(session: RetainedSession, signal: AbortSignal, probe = false): Promise<void> {
if (!probe && (await this.#state?.has(session.conversationKey, session.cwd))) {
throw new AcpRuntimeError(
'ACP conversation is history-only after the Plugin or Host was restarted',
'acp_history_only',
);
}
const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS);
const startupSignal = AbortSignal.any([signal, timeout]);
startupSignal.throwIfAborted();
const launch = this.#configured.launch;
const executable = await checkedExecutable(launch.executable);
await Promise.all((launch.requiredExecutables ?? []).map(checkedExecutable));
const owner = this.#createConnection({
...launch,
executable,
args: launch.args ?? [],
cwd: launch.cwd ?? dirname(executable),
env: launch.env ?? process.env,
clientName: this.#adapter.clientName ?? `maka-${this.id}`,
configureClient: (app) => this.#configureClient(session, app),
});
session.owner = owner;
session.connection = owner.connection;
void owner.failed.catch(() => this.#lose(session)).catch(() => undefined);
const initialized = await Promise.race([
owner.connection.agent.request(
methods.agent.initialize,
{
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: !probe, writeTextFile: !probe },
terminal: false,
},
},
{ cancellationSignal: startupSignal },
),
owner.failed,
]);
if (initialized.protocolVersion !== 1) throw new Error('Unsupported ACP protocol version');
if (!probe) {
startupSignal.throwIfAborted();
// Reserve continuity before asking the Agent to create a Session. A failed
// or interrupted session/new may still have created one remotely.
await this.#state?.mark(session.conversationKey, session.cwd);
session.continuityReserved = true;
}
const created = await Promise.race([
owner.connection.agent.request(
methods.agent.session.new,
{ cwd: session.cwd, mcpServers: [] },
{ cancellationSignal: startupSignal },
),
owner.failed,
]);
session.acpSessionId = created.sessionId;
session.configOptions = created.configOptions ?? [];
if (!probe) {
await this.#applyInitialConfig(
session,
session.configuration?.model
? { model: session.configuration.model }
: (launch.initialConfig ?? {}),
startupSignal,
);
}
}
async #applyInitialConfig(
session: RetainedSession,
values: Readonly<Record<string, string>>,
signal: AbortSignal,
restoreOnFailure = false,
): Promise<void> {
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')
throw new AcpRuntimeError(
`ACP configuration is unavailable: ${key}`,
'acp_config_unavailable',
);
const options = option.options.flatMap((entry) =>
'options' in entry ? entry.options : [entry],
);
if (!options.some((entry) => entry.value === value))
throw new AcpRuntimeError(
`ACP configuration value is unavailable: ${key}`,
'acp_config_invalid',
);
if (option.currentValue === value) continue;
signal.throwIfAborted();
try {
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)
throw new AcpRuntimeError(
'Agent did not confirm the selected configuration',
'acp_config_unconfirmed',
);
session.configOptions = updated.configOptions;
} 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 {
const rollback = await session.connection!.agent.request(
methods.agent.session.setConfigOption,
{ sessionId: session.acpSessionId!, configId: option.id, value: option.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;
restored = true;
}
} catch {
/* Uncertain configuration remains history-only. */
}
}
if (!restored) await this.#lose(session);
throw error;
}
}
}
#configureClient(session: RetainedSession, app: ClientApp): void {
app
.onNotification(methods.client.session.update, ({ params }) => {
if (params.sessionId === session.acpSessionId) this.#acceptUpdate(session, params.update);
})
.onRequest(methods.client.fs.readTextFile, async ({ params }) => {
this.#assertSession(session, params.sessionId);
return {
content: await readWorkspaceTextFile(session.cwd, params.path, params.line, params.limit),
};
})
.onRequest(methods.client.fs.writeTextFile, async ({ params }) => {
this.#assertSession(session, params.sessionId);
await writeWorkspaceTextFile(session.cwd, params.path, params.content);
return {};
})
.onRequest(methods.client.session.requestPermission, ({ params }) =>
this.#requestPermission(session, params),
);
}
async #requestPermission(session: RetainedSession, request: RequestPermissionRequest) {
this.#assertSession(session, request.sessionId);
const active = session.active;
if (!active) return { outcome: { outcome: 'cancelled' as const } };
const outcome = await active.context.requestPermission({
kind: this.#adapter.permissionKind?.(request) ?? 'permission',
toolCallId: request.toolCall.toolCallId,
title: request.toolCall.title || `${this.displayName} requests permission`,
options: request.options.map((option) => ({ optionId: option.optionId, name: option.name })),
});
return { outcome };
}
#acceptUpdate(session: RetainedSession, update: SessionUpdate): void {
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;
return;
}
const active = session.active;
if (!active) return;
if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') {
active.text += update.content.text;
emitText(active.context, 'output_delta', update.content.text);
return;
}
if (update.sessionUpdate === 'agent_thought_chunk' && update.content.type === 'text') {
emitText(active.context, 'thinking_delta', update.content.text);
return;
}
if (update.sessionUpdate === 'tool_call') this.#acceptTool(active, update);
if (update.sessionUpdate === 'tool_call_update') this.#acceptTool(active, update);
}
#acceptTool(active: ActivePrompt, update: ToolCall | ToolCallUpdate): void {
const snapshot = active.tools.get(update.toolCallId) ?? {
id: update.toolCallId,
title: 'External tool',
content: [],
output: '',
outputText: '',
started: false,
terminal: false,
};
if ('title' in update && update.title != null) snapshot.title = update.title;
if (update.name != null) snapshot.name = update.name;
if (update.kind != null) snapshot.kind = update.kind;
if (update.status != null) snapshot.status = update.status;
if (update.content != null) snapshot.content = [...update.content];
if (update.rawInput !== undefined) snapshot.rawInput = update.rawInput;
if (update.rawOutput !== undefined) snapshot.rawOutput = update.rawOutput;
active.tools.set(snapshot.id, snapshot);
if (!snapshot.started) {
active.context.emit({
type: 'tool_start',
toolCallId: snapshot.id,
name: toolName(snapshot.name ?? snapshot.kind ?? 'external_tool'),
displayName: boundedText(snapshot.title),
input: snapshot.rawInput ?? {},
activityKind: activityKind(snapshot.kind),
});
snapshot.started = true;
}
if (snapshot.status !== 'completed' && snapshot.status !== 'failed') {
const output = summarizeToolContent(snapshot.content);
if (output.startsWith(snapshot.output) && output.length > snapshot.output.length) {
emitToolOutput(active.context, snapshot.id, output.slice(snapshot.output.length));
snapshot.output = output;
snapshot.outputText = toolTextContent(snapshot.content);
}
}
if (!snapshot.terminal && (snapshot.status === 'completed' || snapshot.status === 'failed')) {
if (snapshot.content.some((item) => item.type === 'diff')) {
const text = toolTextContent(snapshot.content);
const remaining = text.startsWith(snapshot.outputText)
? text.slice(snapshot.outputText.length)
: text;
if (remaining) emitToolOutput(active.context, snapshot.id, remaining);
}
active.context.emit({
type: 'tool_result',
toolCallId: snapshot.id,
content: projectToolResult(snapshot.content, snapshot.rawOutput),
...(snapshot.status === 'failed' ? { isError: true } : {}),
});
snapshot.terminal = true;
}
}
async #awaitPrompt<T>(
session: RetainedSession,
prompt: Promise<T>,
signal: AbortSignal,
): Promise<T | undefined> {
const running = Promise.race([prompt, session.owner!.failed]);
if (!signal.aborted) {
let onAbort!: () => void;
const aborted = new Promise<void>((resolveAbort) => {
onAbort = resolveAbort;
signal.addEventListener('abort', onAbort, { once: true });
});
try {
await Promise.race([running.then(() => undefined), aborted]);
} finally {
signal.removeEventListener('abort', onAbort);
}
}
if (!signal.aborted) return await running;
let timeout: ReturnType<typeof setTimeout> | undefined;
// Cancellation notification and settlement share one deadline; a blocked stdin cannot hang stop.
const result = await Promise.race([
(async () => {
await session.connection?.agent
.notify(methods.agent.session.cancel, { sessionId: session.acpSessionId! })
.catch(() => undefined);
return { response: await running };
})(),
new Promise<undefined>((resolveTimeout) => {
timeout = setTimeout(() => resolveTimeout(undefined), CANCEL_TIMEOUT_MS);
}),
]).finally(() => clearTimeout(timeout));
if (!result) await this.#lose(session);
return result?.response;
}
#assertSession(session: RetainedSession, sessionId: string): void {
if (!session.acpSessionId || session.acpSessionId !== sessionId)
throw new Error('Unknown ACP Session');
}
async #lose(session: RetainedSession): Promise<void> {
if (!session.loss) {
session.lost = true;
session.loss = this.#disposeSession(session);
}
await session.loss;
}
async #disposeSession(session: RetainedSession): Promise<void> {
const owner = session.owner;
session.owner = undefined;
session.connection = undefined;
if (owner) await owner.dispose();
}
}
function toolTextContent(content: readonly ToolCallContent[]): string {
return content
.flatMap((item) =>
item.type === 'content' && item.content.type === 'text' ? [item.content.text] : [],
)
.join('\n');
}
/**
* Shared ACP registration surface.
*
* Installed Plugin packages are self-contained bundles, so this class must not
* rely on `instanceof Service` across package generations. The adapter passes
* its own Context explicitly; that preserves the child Entry identity used by
* PluginExecutorService even when the ACP runtime was loaded from another
* immutable package generation.
*/
export class AcpRuntimeService {
constructor(ctx: Context) {
ctx.provide('acp', this);
}
register<TConfig>(
consumer: Context,
adapter: AcpAgentAdapter<TConfig>,
config: TConfig,
): Disposable<Promise<void>> {
const storage = consumer.get<PluginStorageService>('storage');
if (!storage) throw new Error('ACP continuity storage is unavailable');
const provider = new AcpExecutor(adapter as AcpAgentAdapter, config, {
state: pluginStateStore(storage, adapter.id),
});
consumer.effect(() => () => provider.dispose(), `acp.dispose(${JSON.stringify(adapter.id)})`);
return consumer.executors.register(provider);
}
}
function pluginStateStore(
storage: PluginStorageService,
executorId: string,
): AcpConversationStateStore {
const key = (conversationKey: string) =>
`acp/${executorId}/${createHash('sha256').update(conversationKey).digest('hex')}`;
return {
async has(conversationKey) {
const value = (await storage.get<{ version?: unknown; cwd?: unknown }>(key(conversationKey)))
.value;
return value?.version === 1;
},
async mark(conversationKey, cwd) {
await storage.set(key(conversationKey), { version: 1, cwd });
},
};
}
function validateAdapter<T>(adapter: AcpAgentAdapter<T>): AcpAgentAdapter<T> {
if (
!adapter ||
typeof adapter !== 'object' ||
typeof adapter.id !== 'string' ||
!/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u.test(adapter.id) ||
typeof adapter.displayName !== 'string' ||
!adapter.displayName.trim() ||
(adapter.clientName !== undefined &&
(typeof adapter.clientName !== 'string' || !adapter.clientName.trim())) ||
typeof adapter.configure !== 'function'
) {
throw new TypeError('Invalid ACP Agent adapter');
}
return adapter;
}
function validateConfiguredAgent(value: AcpConfiguredAgent): AcpConfiguredAgent {
if (!value || typeof value !== 'object' || !value.launch || typeof value.launch !== 'object')
throw new TypeError('Invalid ACP Agent configuration');
if (value.launch.args !== undefined && !Array.isArray(value.launch.args))
throw new TypeError('ACP launch arguments are invalid');
if (
value.launch.requiredExecutables !== undefined &&
!Array.isArray(value.launch.requiredExecutables)
)
throw new TypeError('ACP required executables are invalid');
const executable = absolutePath(value.launch.executable, 'executable');
const args = value.launch.args?.map((argument) => {
if (typeof argument !== 'string' || /[\0\r\n]/u.test(argument))
throw new TypeError('ACP launch argument is invalid');
return argument;
});
const requiredExecutables = value.launch.requiredExecutables?.map((path) =>
absolutePath(path, 'required executable'),
);
const cwd = value.launch.cwd === undefined ? undefined : absolutePath(value.launch.cwd, 'cwd');
const initialConfig = validateInitialConfig(value.launch.initialConfig);
return Object.freeze({
launch: Object.freeze({
...value.launch,
executable,
...(args ? { args: Object.freeze(args) } : {}),
...(requiredExecutables ? { requiredExecutables: Object.freeze(requiredExecutables) } : {}),
...(cwd ? { cwd } : {}),
...(value.launch.env ? { env: Object.freeze({ ...value.launch.env }) } : {}),
...(initialConfig ? { initialConfig } : {}),
}),
});
}
function validateInitialConfig(
value: Readonly<Record<string, string>> | undefined,
): Readonly<Record<string, string>> | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== 'object' || Array.isArray(value))
throw new TypeError('ACP initial configuration is invalid');
const entries = Object.entries(value);
if (
entries.length > 64 ||
entries.some(
([key, item]) =>
!/^[a-z][a-z0-9]*(?:[-_.][a-z0-9]+)*$/u.test(key) ||
typeof item !== 'string' ||
/[\0\r\n]/u.test(item),
)
)
throw new TypeError('ACP initial configuration is invalid');
return Object.freeze(Object.fromEntries(entries));
}
function absolutePath(value: unknown, label: string): string {
if (typeof value !== 'string' || !isAbsolute(value) || /[\0\r\n]/u.test(value))
throw new TypeError(`ACP ${label} must be an absolute path`);
return resolve(value);
}
async function checkedExecutable(path: string): Promise<string> {
const resolved = await realpath(path);
if (!(await stat(resolved)).isFile()) throw new Error('ACP executable is not a file');
await access(resolved, constants.X_OK);
return resolved;
}
function failure(
message: string,
code: string,
recoverable = false,
): Extract<PluginExecutorResult, { status: 'failed' }> {
return { status: 'failed', message, code, recoverable };
}
function errorCode(error: unknown): string {
if (error instanceof AcpRuntimeError) return error.code;
if (error instanceof DOMException && error.name === 'TimeoutError')
return 'acp_initialize_timed_out';
return 'acp_execution_failed';
}
function safeErrorMessage(error: unknown): string {
if (error instanceof AcpRuntimeError) return error.message;
return 'ACP execution failed';
}
const host = Object.freeze({
apply(ctx: Context) {
new AcpRuntimeService(ctx);
},
});
export default Object.freeze({
packageId: 'acp-executor',
contributions: Object.freeze([{ id: 'acp', kind: 'service' }]),
host,
});
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../runtime" }]
}
+73
View File
@@ -0,0 +1,73 @@
<!--
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.
-->
# Antigravity ACP executor Plugin
This package is the thin Antigravity adapter for Maka's shared ACP Runtime Plugin. It owns only the
Antigravity executable/helper paths, launch environment, and optional initial model value. The parent
`acp-runtime` Entry owns ACP processes and Sessions; Maka sees only the registered
`antigravity-acp` executor and ordinary Session events.
Build the repository, install this directory with `plugin.package.install`, then add a profile Entry:
```json
{
"type": "insert",
"rootId": "profile",
"parentId": "acp-runtime",
"entry": {
"id": "antigravity-acp",
"packageId": "antigravity-acp",
"config": {
"executable": "/absolute/path/to/agy_acp_server.par"
}
}
}
```
The parent Entry is contributed when the `acp-executor` dependency is installed.
In production, Runtime Host ships both bundles and derives this Entry from the executable saved by
the existing external-Agent setup flow. The derived package layer is content-addressed, restored on
restart, and removed when the setting is cleared; the adapter itself never reads RuntimePolicy.
Create a Session with `executorId: "antigravity-acp"`. The executable and its
`localharness_external` helper remain adapter-owned. An optional `model` configuration is validated
against the live ACP Session before the first prompt.
## Process lifetime and current limits
After its first prompt, each Maka conversation retains one external ACP process, including while it
waits for the next turn. Closing the Desktop UI does not retire the conversation. PR 2 has no idle
timeout or process-count cap, so a long-running Host can accumulate processes as conversations are
created. This preserves multi-turn continuity while PR 2 cannot restore an external Session after
process loss. Retiring a task releases its process; reloading, disabling, or uninstalling the Plugin
and shutting down the Host also terminate owned process trees. A lost process leaves readable Maka
history, but that task cannot continue until same-Session restoration is implemented and verified
in PR 3. Bounded retention is tracked separately in
[#5620](https://github.com/apache/maka/issues/5620), informed by that restoration behavior.
The ACP `session/new` request currently passes `mcpServers: []`, so this integration does not
forward Maka MCP servers to Antigravity. PR 2 execution acceptance reused an existing Google login;
it does not establish recovery from expired authentication during a task. Fresh interactive login
was verified in the PR 1 setup flow.
The first successful model catalog is cached for the lifetime of the executor Entry. Server-side
model changes without an Entry replacement are not reflected until the Plugin is reloaded. PR 4
owns catalog invalidation and refresh reconciliation.
@@ -0,0 +1,30 @@
{
"schemaVersion": 1,
"id": "antigravity-acp",
"displayName": "Antigravity ACP",
"description": "Runs Google Antigravity as an external Agent through ACP.",
"dependencies": [{ "id": "acp-executor" }],
"runtime": {
"entry": "dist/plugin.mjs"
},
"configuration": {
"properties": {
"executable": {
"type": "string",
"title": "ACP executable",
"description": "Absolute path to agy_acp_server.par."
},
"helper": {
"type": "string",
"title": "ACP helper",
"description": "Optional absolute path to localharness_external; defaults beside the executable."
},
"model": {
"type": "string",
"title": "Model",
"description": "Optional ACP model configuration value applied when each external Session is created."
}
},
"required": ["executable"]
}
}
@@ -0,0 +1,31 @@
{
"name": "@maka/antigravity-acp-plugin",
"version": "0.1.0",
"license": "Apache-2.0",
"description": "Antigravity ACP Session executor packaged for Maka's Host Plugin Platform.",
"type": "module",
"private": true,
"files": [
"dist"
],
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": "./dist/index.js",
"./plugin": "./dist/plugin.mjs"
},
"scripts": {
"clean": "node ../../scripts/clean-paths.mjs dist tsconfig.tsbuildinfo",
"build": "tsc -p tsconfig.json && esbuild src/index.ts --bundle --platform=node --format=esm --target=node22 --outfile=dist/plugin.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test:dist": "node --test \"dist/**/*.test.js\""
},
"dependencies": {
"@maka/acp-executor-plugin": "0.1.0"
},
"devDependencies": {
"@maka/runtime": "0.1.0",
"@agentclientprotocol/sdk": "1.4.0",
"@maka/core": "0.1.0"
}
}
@@ -0,0 +1,162 @@
/*
* 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 { test } from 'node:test';
import acpPackage from '@maka/acp-executor-plugin';
import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader';
import { PluginExecutorService } from '@maka/runtime/plugin-executor-service';
import { PluginStorageService } from '@maka/runtime/plugin-data-services';
import { Context } from '@maka/runtime/plugin-kernel';
import pluginPackage, {
ANTIGRAVITY_ACP_EXECUTOR_ID,
antigravityAcpAdapter,
antigravityEnvironment,
validateConfig,
} from '../index.js';
test('adapter registers through a parent ACP Runtime Entry', async () => {
const root = new Context();
const executors = new PluginExecutorService(root);
new PluginStorageService(root);
const loader = new MakaCompositionLoader({ root });
await loader.install(acpPackage);
await loader.install(pluginPackage);
await loader.create('profile', {
id: 'acp-runtime-entry',
packageId: 'acp-executor',
isolate: { acp: true },
children: [
{
id: 'antigravity-entry',
packageId: 'antigravity-acp',
config: { executable: '/opt/antigravity/agy_acp_server.par' },
},
],
});
assert.deepEqual(
executors.list('session-a').map(({ id, displayName }) => ({ id, displayName })),
[{ id: ANTIGRAVITY_ACP_EXECUTOR_ID, displayName: 'Antigravity' }],
);
assert.deepEqual(pluginPackage.contributions, [
{ id: ANTIGRAVITY_ACP_EXECUTOR_ID, kind: 'executor' },
]);
await loader.close();
});
test('adapter owns Antigravity launch policy only', () => {
const configured = antigravityAcpAdapter.configure({
executable: '/opt/antigravity/agy_acp_server.par',
helper: '/opt/antigravity/localharness_external',
model: 'gemini-high',
});
assert.equal(configured.launch.executable, '/opt/antigravity/agy_acp_server.par');
assert.equal(configured.launch.cwd, '/opt/antigravity');
assert.deepEqual(configured.launch.requiredExecutables, [
'/opt/antigravity/localharness_external',
]);
assert.equal(
configured.launch.env?.ANTIGRAVITY_HARNESS_PATH,
'/opt/antigravity/localharness_external',
);
assert.deepEqual(configured.launch.initialConfig, { model: 'gemini-high' });
});
test('adapter validates configuration and preserves proxy bypass', () => {
assert.throws(() => validateConfig({ executable: 'relative' }), /absolute path/u);
const env = antigravityEnvironment({ NO_PROXY: 'example.test,localhost' }, '/helper');
assert.equal(env.BROWSER, '/usr/bin/true');
assert.equal(env.ANTIGRAVITY_HARNESS_PATH, '/helper');
assert.equal(env.NO_PROXY, 'example.test,localhost,127.0.0.1,::1');
assert.equal(env.no_proxy, env.NO_PROXY);
});
test('official interaction requests are questions; ordinary tool permissions stay permissions', () => {
const classify = antigravityAcpAdapter.permissionKind!;
const request = {
sessionId: 'test',
toolCall: {
toolCallId: 'interaction_fixture',
title: 'Alpha or beta?',
status: 'pending' as const,
},
options: [
{ optionId: '1', name: 'Alpha', kind: 'allow_once' as const },
{ optionId: '2', name: 'Beta', kind: 'allow_once' as const },
],
};
assert.equal(classify(request), 'question');
assert.equal(
classify({
...request,
toolCall: { ...request.toolCall, toolCallId: 'tool-edit', kind: 'edit' },
}),
'permission',
);
});
// Labels are the verified adapter contract; IDs must never be synthesized.
test('official variants group complete and partial families while preserving opaque IDs', () => {
const result = antigravityAcpAdapter.describeModels!([
{ id: 'flash-high', name: 'Gemini 3.8 Flash (High)' },
{ id: 'flash-mid', name: 'Gemini 3.8 Flash (Medium)' },
{ id: 'flash-low', name: 'Gemini 3.8 Flash (Low)' },
{ id: 'gemini-pro-agent', name: 'Gemini 3.1 Pro (High)' },
{ id: 'pro-low', name: 'Gemini 3.1 Pro (Low)' },
{ id: 'other', name: 'Unknown (High)' },
]);
assert.deepEqual(result.modelGroups, [
{
id: 'Gemini 3.8 Flash',
name: 'Gemini 3.8 Flash',
variants: [
{ modelId: 'flash-low', level: 'low' },
{ modelId: 'flash-mid', level: 'medium' },
{ modelId: 'flash-high', level: 'high' },
],
},
{
id: 'Gemini 3.1 Pro',
name: 'Gemini 3.1 Pro',
variants: [
{ modelId: 'pro-low', level: 'low' },
{ modelId: 'gemini-pro-agent', level: 'high' },
],
},
]);
assert.equal(result.models[0]?.providerType, 'google');
assert.deepEqual(result.models.at(-1), { id: 'other', name: 'Unknown (High)' });
});
for (const names of [
['Gemini 3.8 Flash (High)'],
['Gemini 3.8 Flash (High)', 'Gemini 3.8 Flash (High)', 'Gemini 3.8 Flash (Low)'],
['Gemini 3.8 Flash (High)', 'Gemini 3.8 Flash (Low)', 'Gemini 3.8 Flash (Auto)'],
['Unrecognized (High)', 'Unrecognized (Low)'],
])
test(`ambiguous or non-switchable families keep their original rows: ${names.join(', ')}`, () => {
const models = names.map((name, index) => ({ id: `opaque-${index}`, name }));
const result = antigravityAcpAdapter.describeModels!(models);
assert.deepEqual(result.modelGroups, []);
assert.deepEqual(
result.models.map(({ id, name }) => ({ id, name })),
models,
);
});
@@ -0,0 +1,104 @@
/*
* 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 { describeAntigravityModels } from './model-presentation.js';
import { dirname, isAbsolute, resolve } from 'node:path';
import type { RequestPermissionRequest } from '@agentclientprotocol/sdk';
import type { AcpAgentAdapter, AcpConfiguredAgent } from '@maka/acp-executor-plugin';
import type { Context } from '@maka/runtime/plugin-kernel';
export const ANTIGRAVITY_ACP_EXECUTOR_ID = 'antigravity-acp';
export interface AntigravityAcpConfig {
readonly executable: string;
readonly helper?: string;
readonly model?: string;
}
/** Antigravity-specific launch and environment policy; ACP mechanics live in the parent service. */
export const antigravityAcpAdapter: AcpAgentAdapter<AntigravityAcpConfig> = Object.freeze({
id: ANTIGRAVITY_ACP_EXECUTOR_ID,
displayName: 'Antigravity',
clientName: 'maka-antigravity-acp-plugin',
describeModels: describeAntigravityModels,
// Verified with official 1.1.1: structured questions use interaction_ ids and choice options.
permissionKind: (request: RequestPermissionRequest) =>
request.toolCall.toolCallId.startsWith('interaction_') && request.toolCall.kind == null
? ('question' as const)
: ('permission' as const),
executionFailed: (text: string) => text.trimStart().startsWith('Agent execution error:'),
configure(value: AntigravityAcpConfig): AcpConfiguredAgent {
const config = validateConfig(value);
const helper = config.helper ?? resolve(dirname(config.executable), 'localharness_external');
return Object.freeze({
launch: Object.freeze({
executable: config.executable,
requiredExecutables: Object.freeze([helper]),
cwd: dirname(config.executable),
env: antigravityEnvironment(process.env, helper),
...(config.model ? { initialConfig: Object.freeze({ model: config.model }) } : {}),
}),
});
},
});
export function validateConfig(value: AntigravityAcpConfig): AntigravityAcpConfig {
if (!value || typeof value !== 'object') throw new TypeError('ACP Plugin config is required');
const executable = absolutePath(value.executable, 'executable');
const helper = value.helper === undefined ? undefined : absolutePath(value.helper, 'helper');
const model = value.model?.trim();
if (value.model !== undefined && !model) throw new TypeError('ACP Plugin model is invalid');
return Object.freeze({ executable, ...(helper ? { helper } : {}), ...(model ? { model } : {}) });
}
function absolutePath(value: unknown, label: string): string {
if (typeof value !== 'string' || !isAbsolute(value) || /[\0\r\n]/u.test(value))
throw new TypeError(`ACP Plugin ${label} must be an absolute path`);
return resolve(value);
}
export function antigravityEnvironment(base: NodeJS.ProcessEnv, helper: string): NodeJS.ProcessEnv {
const env: NodeJS.ProcessEnv = {
...base,
BROWSER: '/usr/bin/true',
PYTHONUNBUFFERED: '1',
ANTIGRAVITY_HARNESS_PATH: helper,
};
const bypass = [env.NO_PROXY, env.no_proxy, 'localhost', '127.0.0.1', '::1']
.flatMap((entry) => entry?.split(',') ?? [])
.map((entry) => entry.trim())
.filter((entry, index, entries) => entry.length > 0 && entries.indexOf(entry) === index)
.join(',');
env.NO_PROXY = bypass;
env.no_proxy = bypass;
return env;
}
const host = Object.freeze({
inject: ['acp'] as const,
apply(ctx: Context, config: AntigravityAcpConfig) {
ctx.acp.register(ctx, antigravityAcpAdapter, config);
},
});
export default Object.freeze({
packageId: 'antigravity-acp',
contributions: Object.freeze([{ id: ANTIGRAVITY_ACP_EXECUTOR_ID, kind: 'executor' }]),
host,
});
@@ -0,0 +1,67 @@
/*
* 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,
ExecutorModelChoice,
ExecutorModelGroup,
} from '@maka/core/executor-catalog';
import { THINKING_LEVELS, type ThinkingLevel } from '@maka/core/model-thinking';
/** Official ACP 1.1.1 labels encode intensity; IDs are opaque (e.g. gemini-pro-agent). */
export function describeAntigravityModels(
models: readonly ExecutorModelChoice[],
): Pick<ExecutorCatalogEntry, 'models' | 'modelGroups'> {
const families = new Map<string, Array<{ modelId: string; level: ThinkingLevel }>>();
const unrecognized = new Set<string>();
for (const model of models) {
const match = /^(Gemini \d+(?:\.\d+)? (?:Flash|Pro)) \((High|Medium|Low)\)$/.exec(model.name);
if (!match) {
// A bare or unfamiliar variant of the same family makes grouping ambiguous.
const family = /^(Gemini \d+(?:\.\d+)? (?:Flash|Pro))(?:$|[ (])/.exec(model.name)?.[1];
if (family) unrecognized.add(family);
continue;
}
const variants = families.get(match[1]!) ?? [];
variants.push({ modelId: model.id, level: match[2]!.toLowerCase() as ThinkingLevel });
families.set(match[1]!, variants);
}
const modelGroups: ExecutorModelGroup[] = [];
for (const [name, variants] of families) {
if (
unrecognized.has(name) ||
variants.length < 2 ||
new Set(variants.map((v) => v.level)).size !== variants.length
)
continue;
modelGroups.push({
id: name,
name,
variants: [...variants].sort(
(a, b) => THINKING_LEVELS.indexOf(a.level) - THINKING_LEVELS.indexOf(b.level),
),
});
}
return {
models: models.map((model) =>
/^Gemini(?: |$)/.test(model.name) ? { ...model, providerType: 'google' } : model,
),
modelGroups,
};
}
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"composite": true
},
"include": ["src/**/*.ts"],
"references": [{ "path": "../runtime" }, { "path": "../acp-executor-plugin" }]
}
@@ -492,6 +492,7 @@ describe('Runtime Host operator commands', () => {
'peer.mesh.rename',
'peer.mesh.transit.set',
'plugin.composition.apply',
'plugin.executor.query',
'plugin.package.export',
'plugin.package.install',
'plugin.package.reload',
+1
View File
@@ -7,6 +7,7 @@
"sideEffects": false,
"private": true,
"exports": {
"./executor-catalog": "./dist/executor-catalog.js",
"./executor-id": "./dist/executor-id.js",
"./durable-tool-result-projection": "./dist/durable-tool-result-projection.js",
"./model-projection-transition": "./dist/model-projection-transition.js",
@@ -0,0 +1,75 @@
/*
* 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 test from 'node:test';
import { normalizeCatalogEntry, type ExecutorCatalogEntry } from '../executor-catalog.js';
const catalog: ExecutorCatalogEntry = {
id: 'external',
displayName: 'External',
readiness: 'ready',
supportsAttachments: false,
supportsModelChange: true,
models: [
{ id: 'opaque-high', name: 'High', providerType: 'google' },
{ id: 'opaque-low', name: 'Low' },
],
modelGroups: [
{
id: 'base',
name: 'Base',
variants: [
{ modelId: 'opaque-high', level: 'high' },
{ modelId: 'opaque-low', level: 'low' },
],
},
],
};
test('structured capabilities survive wire normalization with immutable exact references', () => {
const output = normalizeCatalogEntry(catalog, 'external');
assert.deepEqual(output, catalog);
assert.ok(Object.isFrozen(output.modelGroups![0]!.variants[0]));
assert.notEqual(output.modelGroups, catalog.modelGroups);
});
for (const variants of [
[
{ modelId: 'invented', level: 'high' },
{ modelId: 'opaque-low', level: 'low' },
],
[
{ modelId: 'opaque-high', level: 'high' },
{ modelId: 'opaque-low', level: 'high' },
],
[
{ modelId: 'opaque-high', level: 'default' },
{ modelId: 'opaque-low', level: 'low' },
],
])
test(`invalid variant capability is rejected: ${JSON.stringify(variants)}`, () => {
assert.throws(() =>
normalizeCatalogEntry(
{
...catalog,
modelGroups: [{ id: 'base', name: 'Base', variants }],
} as ExecutorCatalogEntry,
'external',
),
);
});
+2
View File
@@ -1363,6 +1363,8 @@ export interface CompleteEvent extends BaseEvent {
| 'permission_handoff'
| 'step_limit'
| 'max_tokens';
/** External provider terminal reason, retained even when the caller cancelled the turn. */
providerStopReason?: string;
/** Durable result of an explicit context-compaction execution. */
contextCompactionOutcome?: ContextCompactionOutcome;
}
+179
View File
@@ -0,0 +1,179 @@
/*
* 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 { isExecutorId } from './executor-id.js';
import { CATALOG_PROVIDER_TYPES, type ProviderType } from './provider-registry.js';
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;
}
export interface ExecutorSelection {
readonly executorId: string;
readonly configuration: ExecutorConfiguration;
}
export type ExecutorReadiness =
| 'ready'
| 'unavailable'
| 'authentication_required'
| 'history_only';
export interface ExecutorModelChoice {
readonly id: string;
readonly name: string;
readonly providerType?: ProviderType;
}
/** Presentation capability only: every variant references a real catalog model ID. */
export interface ExecutorModelGroup {
readonly id: string;
readonly name: string;
readonly variants: readonly { readonly modelId: string; readonly level: ThinkingLevel }[];
}
export interface ExecutorCatalogEntry {
readonly id: string;
readonly displayName: string;
readonly readiness: ExecutorReadiness;
readonly models: readonly ExecutorModelChoice[];
readonly modelGroups?: readonly ExecutorModelGroup[];
readonly currentModel?: string;
readonly supportsAttachments: boolean;
readonly supportsModelChange: boolean;
}
export function isExecutorConfiguration(value: unknown): value is ExecutorConfiguration {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const record = value as Record<string, unknown>;
return (
Object.keys(record).every((key) => key === 'model') &&
(record.model === undefined ||
(typeof record.model === 'string' &&
record.model.length > 0 &&
record.model.length <= 1024 &&
!/[\0\r\n]/u.test(record.model)))
);
}
export function normalizeCatalogEntry(
value: ExecutorCatalogEntry,
id: string,
): ExecutorCatalogEntry {
if (
!value ||
!isExecutorId(id) ||
value.id !== id ||
!isCatalogText(value.displayName) ||
!['ready', 'unavailable', 'authentication_required', 'history_only'].includes(
value.readiness,
) ||
!Array.isArray(value.models) ||
value.models.length > 256 ||
!Array.from(value.models).every(
(model) =>
model &&
isExecutorConfiguration({ model: model.id }) &&
typeof model.id === 'string' &&
isCatalogText(model.name) &&
(model.providerType === undefined || CATALOG_PROVIDER_TYPES.includes(model.providerType)),
) ||
new Set(value.models.map((model) => model.id)).size !== value.models.length ||
!isExecutorConfiguration({ model: value.currentModel }) ||
typeof value.supportsAttachments !== 'boolean' ||
typeof value.supportsModelChange !== 'boolean'
)
throw new TypeError('Executor catalog is invalid');
const usedModels = new Set<string>();
const groupIds = new Set<string>();
if (
value.modelGroups !== undefined &&
(!Array.isArray(value.modelGroups) ||
value.modelGroups.length > value.models.length ||
!value.modelGroups.every((group) => {
if (
!group ||
!isExecutorConfiguration({ model: group.id }) ||
typeof group.id !== 'string' ||
!isCatalogText(group.name) ||
groupIds.has(group.id) ||
!Array.isArray(group.variants) ||
group.variants.length < 2 ||
group.variants.length > 7
)
return false;
groupIds.add(group.id);
const levels = new Set<ThinkingLevel>();
return group.variants.every((variant: ExecutorModelGroup['variants'][number]) => {
if (
!variant ||
!isThinkingLevel(variant.level) ||
levels.has(variant.level) ||
usedModels.has(variant.modelId) ||
!value.models.some((model) => model.id === variant.modelId)
)
return false;
levels.add(variant.level);
usedModels.add(variant.modelId);
return true;
});
}))
)
throw new TypeError('Executor model groups are invalid');
return Object.freeze({
id,
displayName: value.displayName,
readiness: value.readiness,
models: Object.freeze(
value.models.map(({ id, name, providerType }) =>
Object.freeze({
id,
name,
...(providerType !== undefined ? { providerType } : {}),
}),
),
),
...(value.modelGroups !== undefined
? {
modelGroups: Object.freeze(
value.modelGroups.map((group) =>
Object.freeze({
id: group.id,
name: group.name,
variants: Object.freeze(
group.variants.map(({ modelId, level }: ExecutorModelGroup['variants'][number]) =>
Object.freeze({ modelId, level }),
),
),
}),
),
),
}
: {}),
...(value.currentModel !== undefined ? { currentModel: value.currentModel } : {}),
supportsAttachments: value.supportsAttachments,
supportsModelChange: value.supportsModelChange,
});
}
function isCatalogText(value: unknown): value is string {
return typeof value === 'string' && value.length <= 8_192 && !/[\0\r]/u.test(value);
}
+3
View File
@@ -17,6 +17,8 @@
* under the License.
*/
import type { ExecutorConfiguration } from './executor-catalog.js';
/**
* Inputs to runtime APIs (create session, send message, list/filter).
*/
@@ -55,6 +57,7 @@ export interface CreateSessionInput {
name?: string;
/** Named plugin executor. When present, model fields are retained only as display placeholders. */
executorId?: string;
executorConfig?: ExecutorConfiguration;
/** Immutable Connection entity identity. Omitted only while copying legacy state. */
llmConnectionId?: string;
llmConnectionSlug: string;
+4
View File
@@ -17,6 +17,8 @@
* under the License.
*/
import type { ExecutorConfiguration } from './executor-catalog.js';
import { isWorkHubActionReceipt, type WorkHubActionReceipt } from './workhub-action-result.js';
import { isExecutorId } from './executor-id.js';
import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js';
@@ -299,6 +301,7 @@ export interface SessionHeader {
backend: PersistedBackendKind;
/** Named black-box executor contributed by a plugin. Present exactly for plugin-executor. */
executorId?: string;
executorConfig?: ExecutorConfiguration;
/** Immutable Connection entity identity. Optional only on legacy Session records. */
llmConnectionId?: string;
llmConnectionSlug: string;
@@ -411,6 +414,7 @@ export interface SessionSummary {
revisionState?: 'preparing' | 'committed';
backend: PersistedBackendKind;
executorId?: string;
executorConfig?: ExecutorConfiguration;
/** Immutable Connection entity identity. Optional only on legacy summaries. */
llmConnectionId?: string;
llmConnectionSlug: string;
+2
View File
@@ -31,6 +31,8 @@
},
"dependencies": {
"@agentclientprotocol/sdk": "1.4.0",
"@maka/acp-executor-plugin": "0.1.0",
"@maka/antigravity-acp-plugin": "0.1.0",
"@maka/core": "0.1.0",
"@maka/runtime": "0.1.0",
"@maka/storage": "0.1.0",
@@ -0,0 +1,5 @@
{
"epoch": 182,
"files": ["packages/runtime-host/src/protocol/plugin-platform.ts"],
"reason": "The browser-safe refactor moves the unchanged executor catalog normalizer from the Node-only runtime service into core and updates its import. Relative to the executor protocol additions and structured model presentation capabilities covered by epoch 182, this relocation does not change catalog validation, accepted values, output fields, errors, or wire encoding; the renderer no longer evaluates the service's Node crypto dependency."
}
@@ -1,5 +1,5 @@
{
"epoch": 177,
"epoch": 182,
"files": ["packages/runtime-host/src/protocol/index.ts"],
"reason": "Moves the existing interactive composition ID constant into a lightweight module while preserving the exact protocol export and encoded value"
}
@@ -0,0 +1,152 @@
/*
* 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 { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { createDefaultRuntimePolicy, type RuntimePolicy } from '@maka/core/runtime-policy';
import { MakaCompositionLoader } from '@maka/runtime/plugin-composition-loader';
import { PluginExecutorService } from '@maka/runtime/plugin-executor-service';
import { PluginStorageService } from '@maka/runtime/plugin-data-services';
import { Context } from '@maka/runtime/plugin-kernel';
import {
HostBuiltinExternalAgentPluginCoordinator,
resolveBuiltinExternalAgentPluginEntries,
withBuiltinExternalAgentCatalog,
} from '../server/builtin-external-agent-plugins.js';
import { HostPluginPlatform } from '../server/plugin-platform.js';
import { HostPluginDataRuntime } from '../server/plugin-data-runtime.js';
test('built-in setup entries fill missing catalog items without replacing live providers', () => {
const other = {
id: 'another-executor',
displayName: 'Another executor',
readiness: 'ready' as const,
models: [{ id: 'another-model', name: 'Another model' }],
supportsAttachments: false,
supportsModelChange: true,
};
const [placeholder] = withBuiltinExternalAgentCatalog([]);
assert.deepEqual(placeholder, {
id: 'antigravity-acp',
displayName: 'Antigravity',
readiness: 'unavailable',
models: [],
supportsAttachments: false,
supportsModelChange: false,
});
assert.deepEqual(withBuiltinExternalAgentCatalog([other]), [other, placeholder]);
const live = { ...placeholder!, readiness: 'ready' as const, models: other.models };
assert.deepEqual(withBuiltinExternalAgentCatalog([other, live]), [other, live]);
});
test('built-in ACP packages load their production bundles and follow RuntimePolicy', async () => {
const root = await mkdtemp(join(tmpdir(), 'maka-builtin-acp-plugins-'));
let policy = configuredPolicy('/opt/antigravity/agy_acp_server.par');
try {
const first = createPlatform(join(root, 'control'));
await first.platform.recover();
const coordinator = new HostBuiltinExternalAgentPluginCoordinator({
platform: first.platform,
controlDirectory: join(root, 'control'),
readPolicy: async () => ({ revision: 4, policy }),
entries: resolveBuiltinExternalAgentPluginEntries(),
});
await coordinator.recover();
assert.deepEqual(
first.platform.inspectExecutors('profile').map(({ id, entryId, extensionId }) => ({
id,
entryId,
extensionId,
})),
[
{
id: 'antigravity-acp',
entryId: 'antigravity-acp',
extensionId: 'antigravity-acp',
},
],
);
assert.deepEqual(
(await first.platform.packageProjections()).map(({ extensionId }) => extensionId),
['acp-executor', 'antigravity-acp'],
);
const initialEpoch = (await first.platform.status()).authorityEpoch;
await coordinator.reconcile();
assert.equal((await first.platform.status()).authorityEpoch, initialEpoch);
policy = configuredPolicy('/Applications/Antigravity/agy_acp_server.par');
await coordinator.reconcile();
const adapter = first.platform
.desiredComposition()
.roots.profile[0]?.children?.find(({ id }) => id === 'antigravity-acp');
assert.deepEqual(adapter?.config, {
executable: '/Applications/Antigravity/agy_acp_server.par',
});
assert.equal((await first.platform.status()).authorityEpoch, initialEpoch + 1);
await first.platform.close();
const restarted = createPlatform(join(root, 'control'));
await restarted.platform.recover();
const recoveredEpoch = (await restarted.platform.status()).authorityEpoch;
const recoveredCoordinator = new HostBuiltinExternalAgentPluginCoordinator({
platform: restarted.platform,
controlDirectory: join(root, 'control'),
readPolicy: async () => ({ revision: 5, policy }),
entries: resolveBuiltinExternalAgentPluginEntries(),
});
await recoveredCoordinator.recover();
assert.equal((await restarted.platform.status()).authorityEpoch, recoveredEpoch);
assert.equal(restarted.platform.inspectExecutors('profile')[0]?.id, 'antigravity-acp');
policy = createDefaultRuntimePolicy();
await recoveredCoordinator.reconcile();
assert.deepEqual(restarted.platform.inspectExecutors('profile'), []);
assert.deepEqual(await restarted.platform.packageProjections(), []);
await restarted.platform.close();
} finally {
await rm(root, { recursive: true, force: true });
}
});
function createPlatform(controlDirectory: string): {
readonly platform: HostPluginPlatform;
} {
const root = new Context();
const executors = new PluginExecutorService(root);
const storage = new PluginStorageService(root);
storage.bindRuntime(new HostPluginDataRuntime(controlDirectory));
return {
platform: new HostPluginPlatform(controlDirectory, {
composition: new MakaCompositionLoader({ root }),
executors,
}),
};
}
function configuredPolicy(executable: string): RuntimePolicy {
const policy = createDefaultRuntimePolicy();
return {
...policy,
externalAgents: { antigravity: { executable } },
};
}
@@ -2194,6 +2194,109 @@ test('a legacy fake-backend session is refused with the product reason, not a re
});
});
test('production executor admission discovers the Session provider, including profile shadows', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
const sessions = [];
for (const executorId of ['private', 'shared']) {
sessions.push(
await stores.sessionStore.create({
cwd: root,
executorId,
llmConnectionSlug: `executor:${executorId}`,
model: 'before',
permissionMode: 'ask',
}),
);
}
const { composition } = await createCapturedExecutionComposition(owner);
try {
const source = join(root, 'executor-fixture');
await mkdir(source);
await writeFile(
join(source, 'maka.extension.json'),
JSON.stringify({
schemaVersion: 1,
id: 'executor-fixture',
runtime: { entry: 'index.mjs' },
configuration: {
properties: { executorId: { type: 'string' }, model: { type: 'string' } },
required: ['executorId', 'model'],
},
composition: { patch: 'maka.composition.json', structuralDependencies: [] },
}),
);
await writeFile(
join(source, 'index.mjs'),
`export default {
packageId: 'executor-fixture',
contributions: [{ id: 'executor', kind: 'executor' }],
host: { apply(ctx, config) {
ctx.executors.register({
id: config.executorId,
discover: async () => ({
id: config.executorId, displayName: config.executorId, readiness: 'ready',
models: [{ id: config.model, name: config.model }],
supportsAttachments: false, supportsModelChange: true,
}),
inspectConversation: async () => { throw new Error('Admission must discover, not inspect'); },
execute: async () => ({ status: 'completed', text: '' }),
});
} },
};`,
);
await writeFile(
join(source, 'maka.composition.json'),
JSON.stringify([
{
type: 'insert',
rootId: 'profile',
entry: {
id: 'profile-shared',
packageId: 'executor-fixture',
config: { executorId: 'shared', model: 'profile-model' },
},
},
...sessions.map((session) => ({
type: 'insert',
rootId: `session:${session.id}`,
entry: {
id: `session-${session.executorId}`,
packageId: 'executor-fixture',
config: { executorId: session.executorId, model: 'session-model' },
},
})),
]),
);
const installed = await composition.plugins.installPackage(source);
assert.equal(installed.convergence, 'converged', JSON.stringify(installed));
const context: ConnectionContext = {
hostEpoch: 'execution-composition-test',
connectionId: 'executor-admission-client',
principal: 'local_os_user',
acquireResidency: () => ({ release() {} }),
};
for (const session of sessions) {
const current = await stores.sessionStore.readHeaderRecordSnapshot(session.id);
const outcome = await composition.handlers['session.configuration.update'](
{
sessionId: session.id,
expectedRevision: current.revision,
patch: { executorTarget: { executorId: session.executorId!, model: 'session-model' } },
},
context,
);
assert.equal(outcome.ok, true, JSON.stringify(outcome));
const updated = await stores.sessionStore.readHeaderSnapshot(session.id);
assert.equal(updated.model, 'session-model');
assert.deepEqual(updated.executorConfig, { model: 'session-model' });
}
} finally {
await composition.close();
}
});
});
test('production composition orphans ownerless ShellRuns before serving Resource queries', async () => {
await withCompositionRoot(async ({ root, owner }) => {
const stores = await openInteractiveExecutionStoresForWrite(owner.lease);
@@ -3826,6 +3826,39 @@ test('canonical retry omits redundant display text and empty ordered refs', asyn
fixture.coordinator.completeIdle(batch);
});
for (const scenario of ['attachments', 'busy', 'history_only'] as const) {
test(`external executor admission rejects ${scenario} without creating or queuing a Turn`, async () => {
const fixture = createFixture();
fixture.root.readSessionHeader = async () => ({
isArchived: false,
idleOnly: true,
supportsAttachments: false,
...(scenario === 'history_only' ? { unavailableReason: 'Start a new task' } : {}),
});
if (scenario !== 'busy') fixture.setRootState({ kind: 'idle' });
const result = await submitContent(
fixture,
'external-message',
{
text: 'keep my instructions',
...(scenario === 'attachments'
? { attachments: [attachment('external', 'proof.png')] }
: {}),
},
'next_turn',
);
assert.equal(result.ok, false);
if (!result.ok)
assert.equal(
result.error.code,
scenario === 'busy' ? 'session_busy' : 'operation_unavailable',
);
assert.equal(fixture.startCalls(), 0);
assert.equal(fixture.readMessageAdmission('external-message'), undefined);
assert.equal(fixture.drainRequests(), 0);
});
}
function createFixture(
onProjectionChanged?: (sessionId: string) => void,
preflightSessionSnapshot: HostMessageCoordinatorOptions['preflightSessionSnapshot'] = () => true,
@@ -4007,6 +4040,7 @@ function createFixture(
coordinator = new HostMessageCoordinator(options);
return {
coordinator,
root,
admissions,
sessionAdmission,
setRootState: (state: HostMessageRootState) => {
@@ -884,6 +884,7 @@ test('plugin executor creation bypasses model resolution and persists the execut
...base,
backend: 'plugin-executor',
executorId: 'codex',
executorConfig: { model: 'account-model' },
llmConnectionSlug: 'executor:codex',
model: 'gpt-codex',
};
@@ -911,12 +912,14 @@ test('plugin executor creation bypasses model resolution and persists the execut
executorId: 'codex',
executorModel: 'gpt-codex',
thinkingLevel: 'high',
executorConfig: { model: 'gpt-codex' },
},
context,
);
assert.equal(outcome.ok, true, JSON.stringify(outcome));
assert.equal(persistedInput?.executorId, 'codex');
assert.deepEqual(persistedInput?.executorConfig, { model: 'gpt-codex' });
assert.equal(persistedInput?.llmConnectionId, undefined);
assert.equal(persistedInput?.llmConnectionSlug, 'executor:codex');
assert.equal(persistedInput?.model, 'gpt-codex');
@@ -924,9 +927,84 @@ test('plugin executor creation bypasses model resolution and persists the execut
if (outcome.ok && !('kind' in outcome.result)) {
assert.equal(outcome.result.backend, 'plugin-executor');
assert.equal(outcome.result.executorId, 'codex');
assert.deepEqual(outcome.result.executorConfig, { model: 'account-model' });
}
});
for (const selection of [
{ executorModel: 'chosen' },
{ executorModel: 'chosen', executorConfig: {} },
{ executorConfig: { model: 'chosen' } },
{ executorModel: 'chosen', executorConfig: { model: 'chosen' } },
{},
]) {
test(`catalog-managed executor creation pins configuration for ${JSON.stringify(selection)}`, async () => {
let persistedInput: Parameters<CatalogStores['createStableSession']>[0]['input'] | undefined;
const expected =
'executorModel' in selection || 'executorConfig' in selection ? { model: 'chosen' } : {};
const fixture = createFixture({
assertExecutorAvailable: (_session, _executor, configuration) => {
assert.deepEqual(configuration ?? {}, expected);
return configuration ?? {};
},
stores: {
createStableSession: async (args) => {
persistedInput = args.input;
return {
kind: 'existing' as const,
record: headerSnapshot(sessionHeader(args.sessionId, []), 1),
};
},
},
});
const result = await fixture.coordinator.handlers['session.create'](
{
sessionId: fixture.sessionId,
workspace: { kind: 'host_path', path: process.cwd() },
executorId: 'fixture-acp',
...selection,
},
context,
);
assert.equal(result.ok, true, JSON.stringify(result));
assert.deepEqual(persistedInput?.executorConfig, expected);
assert.equal(persistedInput?.model, expected.model ?? 'fixture-acp');
});
}
for (const selection of [
{ executorModel: 'removed' },
{ executorModel: 'removed', executorConfig: {} },
{ executorConfig: { model: 'removed' } },
{ executorModel: 'fast', executorConfig: { model: 'slow' } },
]) {
test(`invalid executor model selection never persists: ${JSON.stringify(selection)}`, async () => {
const fixture = createFixture({
assertExecutorAvailable: (_session, _executor, configuration) => {
if (configuration?.model === 'removed') throw new Error('model unavailable');
},
stores: {
createStableSession: async () => assert.fail('Invalid selection must not persist'),
},
});
const result = await fixture.coordinator.handlers['session.create'](
{
sessionId: fixture.sessionId,
workspace: { kind: 'host_path', path: process.cwd() },
executorId: 'fixture-acp',
...selection,
},
context,
);
assert.equal(result.ok, false);
if (!result.ok)
assert.equal(
result.error.code,
selection.executorModel === 'fast' ? 'invalid_request' : 'operation_unavailable',
);
});
}
test('plugin executor creation fails before persistence when the executor is unavailable', async () => {
let createAttempts = 0;
const fixture = createFixture({
@@ -2070,7 +2148,9 @@ function createFixture(
readonly onProjectChanged?: () => void;
readonly legacyConnectionIdentity?: boolean;
readonly header?: Partial<SessionHeader>;
readonly assertExecutorAvailable?: (sessionId: string, executorId: string) => void;
readonly assertExecutorAvailable?: HostSessionCatalogCoordinatorOptions['assertExecutorAvailable'];
readonly configureExecutor?: HostSessionCatalogCoordinatorOptions['configureExecutor'];
readonly retireExecutor?: HostSessionCatalogCoordinatorOptions['retireExecutor'];
} = {},
) {
const sessionId = 'session-1';
@@ -2160,6 +2240,8 @@ function createFixture(
requestDrain: () => {
drains += 1;
},
...(options.configureExecutor ? { configureExecutor: options.configureExecutor } : {}),
...(options.retireExecutor ? { retireExecutor: options.retireExecutor } : {}),
...(options.assertExecutorAvailable
? { assertExecutorAvailable: options.assertExecutorAvailable }
: {}),
@@ -2362,3 +2444,179 @@ function catalogRecord(header: SessionHeader, revision: number): SessionCatalogR
summary: headerToSummary(header),
};
}
test('executor model changes commit only after idle agent confirmation', async () => {
const order: string[] = [];
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'before' },
},
configureExecutor: async (_header, config) => {
assert.equal(fixture.header().executorConfig?.model, 'before');
assert.equal(config.model, 'after');
order.push('confirmed');
},
});
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(order, ['confirmed']);
assert.equal(fixture.header().executorConfig?.model, 'after');
assert.equal(fixture.drainRequests(), 0);
});
test('failed Session commit restores the confirmed executor model', async () => {
const confirmed: string[] = [];
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'before' },
},
configureExecutor: async (_header, config) => {
confirmed.push(config.model!);
},
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: { model: 'after' } },
},
context,
);
assert.equal(outcome.ok, false);
assert.deepEqual(confirmed, ['after', 'before']);
assert.equal(fixture.header().executorConfig?.model, 'before');
assert.equal(fixture.drainRequests(), 0);
});
test('failed executor rollback retires the external conversation', async () => {
let retired = false;
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'before' },
},
configureExecutor: async (_header, config) => {
if (config.model === 'before') throw new Error('Rollback failed');
},
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: { model: 'after' } },
},
context,
);
assert.equal(outcome.ok, false);
assert.equal(retired, true);
assert.equal(fixture.header().executorConfig?.model, 'before');
assert.equal(fixture.drainRequests(), 0);
});
test('rejected or busy executor model changes preserve durable configuration without draining Host', async () => {
for (const busy of [false, true]) {
let called = false;
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'before' },
},
manager: { runningTurnIds: () => (busy ? ['turn'] : []) },
configureExecutor: async () => {
called = true;
throw new Error('unconfirmed');
},
});
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(called, !busy);
assert.equal(fixture.header().executorConfig?.model, 'before');
assert.equal(fixture.drainRequests(), 0);
}
});
test('native execution settings cannot mutate an external task', async () => {
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'before' },
},
});
for (const patch of [
{ permissionMode: 'bypass' },
{ thinkingLevel: 'high' },
{ collaborationMode: 'plan' },
{ orchestrationMode: 'swarm' },
] as const) {
const outcome = await fixture.coordinator.handlers['session.configuration.update'](
{ sessionId: fixture.sessionId, expectedRevision: fixture.revision(), patch },
context,
);
assert.equal(outcome.ok, false);
if (!outcome.ok) assert.equal(outcome.error.code, 'operation_unavailable');
assert.equal(fixture.header().executorConfig?.model, 'before');
assert.equal(fixture.drainRequests(), 0);
}
});
test('reselecting the persisted executor model still confirms the Agent state and respects busy locks', async () => {
for (const busy of [false, true]) {
let confirmations = 0;
const fixture = createFixture({
header: {
backend: 'plugin-executor',
executorId: 'remote',
executorConfig: { model: 'same' },
},
manager: { runningTurnIds: () => (busy ? ['turn'] : []) },
configureExecutor: async () => {
confirmations++;
},
});
const outcome = await fixture.coordinator.handlers['session.configuration.update'](
{
sessionId: fixture.sessionId,
expectedRevision: fixture.revision(),
patch: { executorConfig: { model: 'same' } },
},
context,
);
assert.equal(outcome.ok, !busy);
assert.equal(confirmations, busy ? 0 : 1);
assert.equal(fixture.header().executorConfig?.model, 'same');
}
});
@@ -277,6 +277,7 @@ describe('Session catalog protocol', () => {
sessionId: 'session-executor',
workspace: { kind: 'project', projectId: 'project-1' },
executorId: 'codex.app-server',
executorConfig: { model: 'account-model' },
},
}),
{
@@ -286,6 +287,7 @@ describe('Session catalog protocol', () => {
sessionId: 'session-executor',
workspace: { kind: 'project', projectId: 'project-1' },
executorId: 'codex.app-server',
executorConfig: { model: 'account-model' },
},
},
);
@@ -712,3 +714,40 @@ function isProtocolError(error: unknown): boolean {
function isInvalidSessionStatus(error: unknown): boolean {
return error instanceof RuntimeHostProtocolError && error.message === 'Invalid Session status';
}
test('executor configuration rejects ambiguous routes and malformed values', () => {
const base = {
sessionId: 'session-executor',
workspace: { kind: 'project', projectId: 'project-1' },
};
for (const executorConfig of [
null,
{ model: '' },
{ model: 'bad\nvalue' },
{ mode: 'yolo' },
{ model: 4 },
]) {
assert.throws(
() =>
decodeClientFrame({
requestId: 'r',
operation: 'session.create',
input: { ...base, executorId: 'remote', executorConfig },
}),
isProtocolError,
);
}
assert.throws(
() =>
decodeClientFrame({
requestId: 'r',
operation: 'session.create',
input: { ...base, modelTarget: { kind: 'default' }, executorConfig: { model: 'other' } },
}),
isProtocolError,
);
assert.throws(
() => decodeSessionCatalogItem({ ...projection(), executorConfig: { model: 'other' } }),
isProtocolError,
);
});
@@ -354,6 +354,49 @@ test('Naming falls back to the Message when the title model is unreachable', asy
);
});
test('Plugin executor naming uses the message without calling a native model', async () => {
const named = gate();
const titles: string[] = [];
await withHarness(
async ({ coordinator }) => {
coordinator.nameSessionFromRootMessage({
sessionId: 'session-1',
content: { text: 'External task title\nMore instructions' },
});
await named.promise;
assert.deepEqual(titles, ['External task title']);
},
{
generateTitle: async () => assert.fail('external execution has no native title model'),
generateRecap: async () => assert.fail('naming must not call recap'),
},
{
readSessionHeader: async () => ({ ...unnamedHeader(), backend: 'plugin-executor' }),
nameSessionIfUnnamed: async (_sessionId, title) => {
titles.push(title);
return unnamedHeader();
},
onSessionNamed: () => named.release(),
},
);
});
test('Plugin executor recap is unavailable without calling a native model or draining', async () => {
await withHarness(
async ({ coordinator, modelCalls }) => {
const result = await coordinator.handlers['session.recap.generate'](
{ sessionId: 'session-1', effectId: 'effect-1', reason: 'manual' },
connectionContext,
);
assert.equal(result.ok, false);
if (!result.ok) assert.equal(result.error.code, 'operation_unavailable');
assert.equal(modelCalls.count, 0);
},
undefined,
{ readSessionHeader: async () => ({ ...unnamedHeader(), backend: 'plugin-executor' }) },
);
});
test('A named Session is never renamed by the effect', async () => {
let modelCalls = 0;
await withHarness(
@@ -60,7 +60,12 @@ export function projectSessionCatalogSummary(
...(session.revisionIndex === undefined ? {} : { revisionIndex: session.revisionIndex }),
...(session.revisionState === undefined ? {} : { revisionState: session.revisionState }),
backend: session.backend,
...(session.executorId ? { executorId: session.executorId } : {}),
...(session.executorId
? {
executorId: session.executorId,
...(session.executorConfig ? { executorConfig: session.executorConfig } : {}),
}
: {}),
...(session.llmConnectionId === null ? {} : { llmConnectionId: session.llmConnectionId }),
llmConnectionSlug: session.llmConnectionSlug,
connectionLocked: session.connectionLocked,
+5 -1
View File
@@ -103,7 +103,11 @@ 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 = 178 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 182 as const;
// 182: Executor catalogs expose structured model families and thinking variant IDs.
// 181: Canonical executor models and retained provider stop reasons after cancellation.
// 180: Reject contradictory executor configuration and legacy model targets.
// 179: Generic executor discovery and confirmed per-Session configuration.
// 178: WorkHub result turns carry a Host-owned workhub_result origin. Older
// Clients reject that origin and cannot render the result notification.
// 177: External Session import input may carry an optional Host-resolved
@@ -17,6 +17,7 @@
* under the License.
*/
import { normalizeCatalogEntry, type ExecutorCatalogEntry } from '@maka/core/executor-catalog';
import {
validateCompositionEntry,
validatePluginRootId,
@@ -100,6 +101,13 @@ export interface PluginPackageProjection {
readonly requiredBy: readonly string[];
}
export type PluginExecutorQueryInput =
| { readonly kind: 'catalog'; readonly cwd: string }
| { readonly kind: 'conversation'; readonly sessionId: string };
export interface PluginExecutorQueryResult {
readonly items: readonly ExecutorCatalogEntry[];
}
export interface PluginPlatformQueryInput {
readonly view:
| 'status'
@@ -323,6 +331,44 @@ export const PLUGIN_PLATFORM_OPERATION_SPECS = {
decodeInput: decodePluginClientRemoteStreamCursorInput,
decodeOutput: decodePluginClientRemoteStreamCloseResult,
}),
'plugin.executor.query': defineOperation<
PluginExecutorQueryInput,
PluginExecutorQueryResult,
(typeof QUERY_ERRORS)[number]
>({
mode: 'query',
availability: 'ready',
errors: QUERY_ERRORS,
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 = requireExactRecord(record, 'Executor conversation', ['kind', 'sessionId']);
if (input.kind !== 'conversation') throw invalidProtocolFrame('Invalid executor query');
return { kind: 'conversation', sessionId: requireId(input.sessionId, 'sessionId') };
},
decodeOutput: (value) => {
requireEncodedByteLimit(value, 'Executor catalog', MAX_FRAME_BYTES);
const output = requireExactRecord(value, 'Executor catalog result', ['items']);
if (!Array.isArray(output.items) || output.items.length > 256)
throw invalidProtocolFrame('Invalid executor catalog');
return {
items: output.items.map((entry) => {
const record = requireRecord(entry, 'Executor catalog entry');
try {
return normalizeCatalogEntry(
record as unknown as ExecutorCatalogEntry,
requireString(record.id, 'executor id', 256),
);
} catch {
throw invalidProtocolFrame('Invalid executor catalog entry');
}
}),
};
},
}),
'plugin.platform.query': defineOperation<
PluginPlatformQueryInput,
PluginPlatformQueryResult,
@@ -17,6 +17,7 @@
* under the License.
*/
import { isExecutorConfiguration, type ExecutorConfiguration } from '@maka/core/executor-catalog';
import { isCollaborationMode, type CollaborationMode } from '@maka/core/collaboration';
import { isOrchestrationMode, type OrchestrationMode } from '@maka/core/orchestration';
import { isPermissionMode, type PermissionMode } from '@maka/core/permission';
@@ -126,6 +127,7 @@ const PROJECTION_FIELDS = [
'revisionIndex',
'revisionState',
'executorId',
'executorConfig',
'thinkingLevel',
'lastReadMessageId',
'liveRunState',
@@ -168,6 +170,7 @@ export interface SessionCreateInput {
readonly executorId?: string;
/** Optional executor-specific model. Only valid with executorId. */
readonly executorModel?: string;
readonly executorConfig?: ExecutorConfiguration;
/** Omitted applies the model preference; null explicitly uses the provider default. */
readonly thinkingLevel?: ThinkingLevel | null;
readonly toolProfile?: SessionToolProfile;
@@ -189,6 +192,7 @@ export interface SessionMetadataUpdateInput {
}
export interface SessionConfigurationPatch {
readonly executorConfig?: ExecutorConfiguration;
readonly modelTarget?: Extract<SessionModelTarget, { readonly kind: 'explicit' }>;
readonly executorTarget?: SessionExecutorTarget;
readonly thinkingLevel?: ThinkingLevel | null;
@@ -252,6 +256,7 @@ export interface SessionCatalogProjection {
readonly revisionState?: 'preparing' | 'committed';
readonly backend: PersistedBackendKind;
readonly executorId?: string;
readonly executorConfig?: ExecutorConfiguration;
readonly llmConnectionId: string | null;
readonly llmConnectionSlug: string;
readonly connectionLocked: boolean;
@@ -537,6 +542,7 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput {
'modelTarget',
'executorId',
'executorModel',
'executorConfig',
'thinkingLevel',
'toolProfile',
'permissionMode',
@@ -547,6 +553,11 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput {
const executorId = Object.hasOwn(input, 'executorId')
? executorIdValue(input.executorId)
: undefined;
if (
input.executorConfig !== undefined &&
(!executorId || !isExecutorConfiguration(input.executorConfig))
)
throw invalidProtocolFrame('Invalid executor configuration');
const target = Object.hasOwn(input, 'modelTarget') ? modelTarget(input.modelTarget) : undefined;
const executorModel = Object.hasOwn(input, 'executorModel')
? requireUtf8String(input.executorModel, 'Executor model', SESSION_CATALOG_MODEL_MAX_BYTES)
@@ -554,6 +565,13 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput {
if ((executorId === undefined) === (target === undefined)) {
throw invalidProtocolFrame('Session creation requires exactly one model target or executor id');
}
if (
executorModel !== undefined &&
(input.executorConfig as ExecutorConfiguration | undefined)?.model !== undefined &&
executorModel !== (input.executorConfig as ExecutorConfiguration).model
) {
throw invalidProtocolFrame('Conflicting executor models');
}
if (executorModel !== undefined && executorId === undefined) {
throw invalidProtocolFrame('Executor model requires an executor id');
}
@@ -566,6 +584,14 @@ export function decodeSessionCreateInput(value: unknown): SessionCreateInput {
...(target ? { modelTarget: target } : {}),
...(executorId ? { executorId } : {}),
...(executorModel ? { executorModel } : {}),
...(executorId
? {
executorId,
...(input.executorConfig
? { executorConfig: input.executorConfig as ExecutorConfiguration }
: {}),
}
: {}),
...(Object.hasOwn(input, 'thinkingLevel')
? { thinkingLevel: input.thinkingLevel === null ? null : thinkingLevel(input.thinkingLevel) }
: {}),
@@ -635,6 +661,7 @@ export function decodeSessionConfigurationUpdateInput(
'Session configuration patch',
[],
[
'executorConfig',
'modelTarget',
'executorTarget',
'thinkingLevel',
@@ -643,6 +670,13 @@ export function decodeSessionConfigurationUpdateInput(
'orchestrationMode',
],
);
if (
patch.executorConfig !== undefined &&
(!isExecutorConfiguration(patch.executorConfig) ||
patch.modelTarget !== undefined ||
patch.executorTarget !== undefined)
)
throw invalidProtocolFrame('Invalid executor configuration');
if (Object.keys(patch).length === 0) {
throw invalidProtocolFrame('Session configuration patch is empty');
}
@@ -653,6 +687,9 @@ export function decodeSessionConfigurationUpdateInput(
sessionId: requireEntityId(input.sessionId, 'sessionId'),
expectedRevision: positiveRevision(input.expectedRevision, 'expected Session revision'),
patch: {
...(patch.executorConfig
? { executorConfig: patch.executorConfig as ExecutorConfiguration }
: {}),
...(Object.hasOwn(patch, 'modelTarget')
? { modelTarget: explicitModelTarget(patch.modelTarget) }
: {}),
@@ -1076,9 +1113,20 @@ function executorTarget(value: unknown): SessionExecutorTarget {
function optionalExecutorId(
record: Record<string, unknown>,
): Pick<SessionCatalogProjection, 'executorId'> | Record<string, never> {
if (record.executorId === undefined) return {};
return { executorId: executorIdValue(record.executorId) };
): Pick<SessionCatalogProjection, 'executorId' | 'executorConfig'> | Record<string, never> {
if (record.executorId === undefined) {
if (record.executorConfig !== undefined)
throw invalidProtocolFrame('Executor configuration requires an executor');
return {};
}
if (record.executorConfig !== undefined && !isExecutorConfiguration(record.executorConfig))
throw invalidProtocolFrame('Invalid executor configuration');
return {
executorId: executorIdValue(record.executorId),
...(record.executorConfig
? { executorConfig: record.executorConfig as ExecutorConfiguration }
: {}),
};
}
function thinkingLevel(value: unknown): ThinkingLevel {
@@ -0,0 +1,253 @@
/*
* 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 { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { ExecutorCatalogEntry } from '@maka/core/executor-catalog';
import type { RuntimePolicySnapshot } from '@maka/core/runtime-policy';
import type { MakaCompositionOperation } from '@maka/runtime/plugin-runtime';
import { extensionPackageDirectoryContentDigest } from './extension-bundle.js';
import type { ExtensionPackageManifest } from './extension-package-manifest.js';
import type { HostPluginPlatform } from './plugin-platform.js';
const ACP_RUNTIME_PACKAGE_ID = 'acp-executor';
const ACP_RUNTIME_ENTRY_ID = 'acp-runtime';
const ANTIGRAVITY_PACKAGE_ID = 'antigravity-acp';
const ANTIGRAVITY_ENTRY_ID = 'antigravity-acp';
const STAGING_DIRECTORY = 'builtin-plugin-staging-v1';
const RUNTIME_ENTRY = 'plugin.mjs';
const COMPOSITION_PATCH = 'maka.composition.json';
// Built-in setup entries remain discoverable before their Plugin is installed.
// Registered Plugins replace these placeholders with their live catalogs.
const BUILTIN_EXTERNAL_AGENT_CATALOG: readonly ExecutorCatalogEntry[] = Object.freeze([
Object.freeze({
id: ANTIGRAVITY_ENTRY_ID,
displayName: 'Antigravity',
readiness: 'unavailable' as const,
models: Object.freeze([]),
supportsAttachments: false,
supportsModelChange: false,
}),
]);
export function withBuiltinExternalAgentCatalog(
catalog: readonly ExecutorCatalogEntry[],
): readonly ExecutorCatalogEntry[] {
const registered = new Set(catalog.map((entry) => entry.id));
return [
...catalog,
...BUILTIN_EXTERNAL_AGENT_CATALOG.filter((entry) => !registered.has(entry.id)),
];
}
export interface BuiltinExternalAgentPluginEntries {
readonly acpRuntime: string;
readonly antigravity: string;
}
interface StagedPackage {
readonly root: string;
readonly digest: string;
dispose(): Promise<void>;
}
/**
* Projects durable product settings into reserved Plugin packages and Entries.
*
* RuntimePolicy remains the owner of setup facts. The generated package layer
* is replaceable derived state, so adapter code never reads RuntimePolicy and
* repeated reconciliation does not append unbounded user composition overlays.
*/
export class HostBuiltinExternalAgentPluginCoordinator {
readonly #platform: Pick<
HostPluginPlatform,
'installPackage' | 'uninstallPackage' | 'packageProjections'
>;
readonly #controlDirectory: string;
readonly #readPolicy: () => Promise<RuntimePolicySnapshot>;
readonly #entries: BuiltinExternalAgentPluginEntries;
#gate: Promise<void> = Promise.resolve();
constructor(input: {
readonly platform: Pick<
HostPluginPlatform,
'installPackage' | 'uninstallPackage' | 'packageProjections'
>;
readonly controlDirectory: string;
readonly readPolicy: () => Promise<RuntimePolicySnapshot>;
readonly entries?: BuiltinExternalAgentPluginEntries;
}) {
this.#platform = input.platform;
this.#controlDirectory = input.controlDirectory;
this.#readPolicy = input.readPolicy;
this.#entries = input.entries ?? resolveBuiltinExternalAgentPluginEntries();
}
async recover(): Promise<void> {
await rm(join(this.#controlDirectory, STAGING_DIRECTORY), { recursive: true, force: true });
return await this.reconcile();
}
reconcile(): Promise<void> {
const task = this.#gate.then(() => this.#reconcileNow());
this.#gate = task.catch(() => undefined);
return task;
}
async #reconcileNow(): Promise<void> {
const executable = (await this.#readPolicy()).policy.externalAgents.antigravity.executable;
if (!executable) {
await this.#removeIfInstalled(ANTIGRAVITY_PACKAGE_ID);
await this.#removeIfInstalled(ACP_RUNTIME_PACKAGE_ID);
return;
}
await this.#ensurePackage(
ACP_RUNTIME_PACKAGE_ID,
this.#entries.acpRuntime,
acpRuntimeManifest(),
[
{
type: 'insert',
rootId: 'profile',
entry: {
id: ACP_RUNTIME_ENTRY_ID,
packageId: ACP_RUNTIME_PACKAGE_ID,
isolate: { acp: true },
},
},
],
);
await this.#ensurePackage(
ANTIGRAVITY_PACKAGE_ID,
this.#entries.antigravity,
antigravityManifest(),
[
{
type: 'insert',
parentId: ACP_RUNTIME_ENTRY_ID,
entry: {
id: ANTIGRAVITY_ENTRY_ID,
packageId: ANTIGRAVITY_PACKAGE_ID,
config: { executable },
},
},
],
);
}
async #ensurePackage(
extensionId: string,
runtimeEntry: string,
manifest: ExtensionPackageManifest,
operations: readonly MakaCompositionOperation[],
): Promise<void> {
const staged = await stagePackage(this.#controlDirectory, runtimeEntry, manifest, operations);
try {
const installed = (await this.#platform.packageProjections()).find(
(candidate) => candidate.extensionId === extensionId,
);
if (installed?.contentDigest === staged.digest) return;
await this.#platform.installPackage(staged.root);
} finally {
await staged.dispose();
}
}
async #removeIfInstalled(extensionId: string): Promise<void> {
const installed = (await this.#platform.packageProjections()).some(
(candidate) => candidate.extensionId === extensionId,
);
if (installed) await this.#platform.uninstallPackage(extensionId);
}
}
export function resolveBuiltinExternalAgentPluginEntries(): BuiltinExternalAgentPluginEntries {
return Object.freeze({
acpRuntime: fileURLToPath(import.meta.resolve('@maka/acp-executor-plugin/plugin')),
antigravity: fileURLToPath(import.meta.resolve('@maka/antigravity-acp-plugin/plugin')),
});
}
async function stagePackage(
controlDirectory: string,
runtimeEntry: string,
manifest: ExtensionPackageManifest,
operations: readonly MakaCompositionOperation[],
): Promise<StagedPackage> {
const stagingRoot = join(controlDirectory, STAGING_DIRECTORY);
await mkdir(stagingRoot, { recursive: true, mode: 0o700 });
const root = await mkdtemp(join(stagingRoot, '.package-'));
try {
await copyFile(runtimeEntry, join(root, RUNTIME_ENTRY));
await writeFile(join(root, 'maka.extension.json'), `${JSON.stringify(manifest, null, 2)}\n`, {
mode: 0o600,
});
await writeFile(join(root, COMPOSITION_PATCH), `${JSON.stringify(operations, null, 2)}\n`, {
mode: 0o600,
});
return Object.freeze({
root,
digest: await extensionPackageDirectoryContentDigest(root),
dispose: () => rm(root, { recursive: true, force: true }),
});
} catch (error) {
await rm(root, { recursive: true, force: true }).catch(() => undefined);
throw error;
}
}
function acpRuntimeManifest(): ExtensionPackageManifest {
return Object.freeze({
schemaVersion: 1,
id: ACP_RUNTIME_PACKAGE_ID,
displayName: 'ACP Executor Runtime',
description: 'Shared ACP protocol and process runtime for external Agent adapter plugins.',
dependencies: Object.freeze([]),
configuration: Object.freeze({ properties: Object.freeze({}), required: Object.freeze([]) }),
runtime: Object.freeze({ entry: RUNTIME_ENTRY }),
composition: Object.freeze({
patch: COMPOSITION_PATCH,
structuralDependencies: Object.freeze([]),
}),
});
}
function antigravityManifest(): ExtensionPackageManifest {
return Object.freeze({
schemaVersion: 1,
id: ANTIGRAVITY_PACKAGE_ID,
displayName: 'Antigravity ACP',
description: 'Runs Google Antigravity as an external Agent through ACP.',
dependencies: Object.freeze([{ id: ACP_RUNTIME_PACKAGE_ID }]),
configuration: Object.freeze({
properties: Object.freeze({
executable: Object.freeze({ type: 'string', title: 'ACP executable' }),
}),
required: Object.freeze(['executable']),
}),
runtime: Object.freeze({ entry: RUNTIME_ENTRY }),
composition: Object.freeze({
patch: COMPOSITION_PATCH,
structuralDependencies: Object.freeze([ACP_RUNTIME_PACKAGE_ID]),
}),
});
}
@@ -196,6 +196,10 @@ export class HostContextCoordinator {
if (isSessionNotFoundError(error)) return notFound('Session does not exist');
throw error;
}
if (header.backend === 'plugin-executor')
return operationUnavailable(
'External executors own their context. Start a new task instead of compacting.',
);
if (header.isArchived) {
return sessionArchived('Cannot compact an archived Session');
}
@@ -216,6 +216,10 @@ import {
} from './project-directory-authority.js';
import { HostProjectCatalogCoordinator } from './project-catalog-coordinator.js';
import { HostProjectMembershipGate } from './project-membership-gate.js';
import {
HostBuiltinExternalAgentPluginCoordinator,
withBuiltinExternalAgentCatalog,
} from './builtin-external-agent-plugins.js';
import { HostPluginPlatformCoordinator } from './plugin-platform-coordinator.js';
import { HostPluginPlatform } from './plugin-platform.js';
import { RootAdmissionOwner } from './root-admission-owner.js';
@@ -397,9 +401,30 @@ export async function createExecutionRuntimeHostComposition(
executors: pluginExecutors,
clientBridge: pluginClientBridge,
});
const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(pluginPlatform);
const pluginPlatformCoordinator = new HostPluginPlatformCoordinator(
pluginPlatform,
async (input) => {
if (input.kind === 'conversation') {
const header = await stores.sessionStore.readHeaderSnapshot(input.sessionId);
if (!header.executorId) return [];
return pluginExecutors.catalog({
sessionId: input.sessionId,
executorId: header.executorId,
cwd: header.cwd,
...(header.executorConfig ? { configuration: header.executorConfig } : {}),
});
}
const catalog = await pluginExecutors.catalog({ cwd: input.cwd });
return withBuiltinExternalAgentCatalog(catalog);
},
);
const openedProjectCatalog = storage.projectCatalog;
const runtimePolicyStores = storage.runtimePolicy;
const builtinExternalAgentPlugins = new HostBuiltinExternalAgentPluginCoordinator({
platform: pluginPlatform,
controlDirectory: context.owner.controlDirectory,
readPolicy: () => runtimePolicyStores.runtimePolicy.getSnapshot(),
});
const oauthCredentials = new HostOAuthExecutionAuthority(runtimePolicyStores);
const openedScheduledTaskStore = storage.scheduledTasks;
const openedPlanStore = storage.plan;
@@ -877,8 +902,32 @@ export async function createExecutionRuntimeHostComposition(
const rootPort: HostMessageRootPort = {
readLatestRootTurnLineage: (identity) =>
requireRootCoordinator(rootCoordinator).readLatestRootTurnLineage(identity),
readSessionHeader: (sessionId) =>
requireRootCoordinator(rootCoordinator).readSessionHeader(sessionId),
readSessionHeader: async (sessionId) => {
const projected =
await requireRootCoordinator(rootCoordinator).readSessionHeader(sessionId);
if (!projected || projected.unavailableReason) return projected;
const header = await stores.sessionStore.readHeaderSnapshot(sessionId);
if (!header.executorId) return projected;
const [entry] = await pluginExecutors.catalog({
sessionId,
executorId: header.executorId,
cwd: header.cwd,
...(header.executorConfig ? { configuration: header.executorConfig } : {}),
});
return {
...projected,
idleOnly: true,
supportsAttachments: entry?.supportsAttachments ?? false,
...(entry?.readiness === 'ready'
? {}
: {
unavailableReason:
entry?.readiness === 'history_only'
? 'External conversation is history-only. Start a new task.'
: 'Executor is unavailable. Check External Agents settings.',
}),
};
},
readRootState: (sessionId) =>
requireRootCoordinator(rootCoordinator).readRootState(sessionId),
claimStopFence: (input, commitQueueFence, admission) =>
@@ -1151,6 +1200,9 @@ export async function createExecutionRuntimeHostComposition(
...(factoryContext.header.thinkingLevel
? { thinkingLevel: factoryContext.header.thinkingLevel }
: {}),
...(factoryContext.header.executorConfig
? { configuration: factoryContext.header.executorConfig }
: {}),
...(factoryContext.systemPrompt ? { instructions: factoryContext.systemPrompt } : {}),
binding,
}),
@@ -2037,6 +2089,7 @@ export async function createExecutionRuntimeHostComposition(
});
async function applyRuntimePolicyMutationEffects(): Promise<void> {
try {
await builtinExternalAgentPlugins.reconcile();
await requireMemory(memory).refreshAfterPolicyMutation();
} catch (error) {
context.requestDrain();
@@ -2059,8 +2112,33 @@ export async function createExecutionRuntimeHostComposition(
continuity: continuityCoordinator,
workspaceResolver,
requestDrain: context.requestDrain,
assertExecutorAvailable: (sessionId, executorId) => {
configureExecutor: async (header, configuration) => {
if (!header.executorId) throw new Error('Session has no executor');
await pluginExecutors.configureConversation(header.id, header.executorId, {
conversationKey: header.id,
cwd: header.cwd,
configuration,
});
},
retireExecutor: (sessionId) => pluginExecutors.retireConversation(sessionId),
assertExecutorAvailable: async (sessionId, executorId, configuration, cwd) => {
pluginExecutors.identity(sessionId, executorId);
const [entry] = await pluginExecutors.catalog({
cwd,
executorId,
discoverySessionId: sessionId,
});
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 (
configuration?.model &&
!entry.models.some((model) => model.id === configuration.model)
)
throw new Error('Executor model is unavailable');
return configuration ?? {};
}
},
...(context.sessionAccessAuthority
? { sessionAccessAuthority: context.sessionAccessAuthority }
@@ -2570,7 +2648,14 @@ export async function createExecutionRuntimeHostComposition(
sessionEffects: sessionEffectCoordinator,
graph: requireGraphCoordinator(graphCoordinator),
graphWake: requireGraphSupervisorWake(graphSupervisorWake),
manager,
manager: {
finalizeChildWorkspacePatches: (sessionId) =>
requireSessionManager(manager).finalizeChildWorkspacePatches(sessionId),
disposeSessionBackend: async (sessionId) => {
await requireSessionManager(manager).disposeSessionBackend(sessionId);
await pluginExecutors.retireConversation(sessionId);
},
},
capabilities: clientCapabilities,
continuity: continuityCoordinator,
artifacts: openedArtifactStore,
@@ -2650,6 +2735,10 @@ export async function createExecutionRuntimeHostComposition(
(connectionId) => pluginPlatformCoordinator.releaseConnection(connectionId),
],
}),
createRuntimeHostDomainModule({
id: 'builtin-external-agent-plugins',
recovery: { state: () => builtinExternalAgentPlugins.recover() },
}),
createRuntimeHostDomainModule({
id: 'memory',
handlers: [requireMemory(memory).handlers],
@@ -591,6 +591,11 @@ async function runHostAuxiliaryModelCall(
readonly finishReason?: string;
readonly modelId: string;
}> {
if (input.header.backend === 'plugin-executor') {
throw new AuxiliaryModelCallConfigurationError(
'Plugin executor Sessions do not provide a native auxiliary model',
);
}
const target = await readAuxiliaryPreflight(authority, input.abortSignal, () =>
readDuringBackendCreation(
() =>
@@ -260,6 +260,11 @@ export function extensionPackageContentDigest(
return `sha256-${hash.digest('hex')}`;
}
/** Computes the canonical digest used by PluginPackageStore without installing the package. */
export async function extensionPackageDirectoryContentDigest(root: string): Promise<string> {
return extensionPackageContentDigest(await readDirectory(root));
}
function safePath(value: string): string {
if (
!value ||
@@ -103,6 +103,8 @@ const EMPTY_SKILL_INVOCATION: SkillInvocationResult = {
};
export interface HostMessageSessionHeader {
readonly idleOnly?: boolean;
readonly supportsAttachments?: boolean;
readonly isArchived: boolean;
readonly unavailableReason?: string;
/** A reserved Session accepts queued messages only while its dedicated root is active. */
@@ -1281,7 +1283,14 @@ export class HostMessageCoordinator implements RuntimeMessageAuthority {
if (header.unavailableReason) {
return failure('operation_unavailable', header.unavailableReason);
}
if (header.supportsAttachments === false && payload.content.attachments?.length)
return failure(
'operation_unavailable',
'Remove unsupported attachments or choose another executor.',
);
const rootState = await this.#root.readRootState(input.sessionId);
if (header.idleOnly && rootState.kind !== 'idle')
return failure('session_busy', 'External executor accepts messages only while idle');
if (this.#failStopped) {
return failure('host_draining', 'Runtime Host message authority has failed');
}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more