diff --git a/apps/desktop/src/main/__tests__/executor-selection.test.ts b/apps/desktop/src/main/__tests__/executor-selection.test.ts
index 17efe588738..1616973d24e 100644
--- a/apps/desktop/src/main/__tests__/executor-selection.test.ts
+++ b/apps/desktop/src/main/__tests__/executor-selection.test.ts
@@ -28,6 +28,54 @@ import { useExecutorSelection, newTaskConfiguration, ConversationServicesProvide
const entry: ExecutorCatalogEntry = { id: 'external', displayName: 'External', readiness: 'ready', models: [{ id: 'selected', name: 'Selected' }], supportsAttachments: false, supportsModelChange: true };
+test('explicit restore confirms the saved model while preserving the current task', async () => {
+ const { document, window } = parseHTML('
');
+ const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true };
+ 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;
+ let ready = false;
+ let finishRestore!: () => void;
+ const confirmation = new Promise((resolve) => { finishRestore = resolve; });
+ const writes: Array<{ sessionId: string; model: string | undefined }> = [];
+ const services = {
+ subscribeChanges: () => () => {},
+ newTasks: { subscribeChanges: () => () => {}, getExecutors: async () => [entry] },
+ sessions: {
+ getExecutorState: async () => [{ ...entry, readiness: ready ? 'ready' : 'restorable', currentModel: 'selected' }],
+ setExecutorModelConfiguration: async (sessionId: string, config: { model?: string }) => {
+ writes.push({ sessionId, model: config.model });
+ await confirmation;
+ ready = true;
+ return { ok: true, session: { executorConfig: config } };
+ },
+ },
+ } as unknown as ConversationServices;
+ function Probe() {
+ latest = useExecutorSelection({
+ key: 'saved', cwd: '/fixture',
+ target: { hostId: 'host', profileId: 'profile', projectId: null },
+ session: { id: 'saved', executorId: 'external', executorConfig: { model: 'selected' } } as SessionSummary,
+ });
+ return null;
+ }
+ try {
+ await act(async () => root.render(createElement(ConversationServicesProvider, { services, children: createElement(Probe) })));
+ assert.equal(latest.entry?.readiness, 'restorable');
+ let restoring!: Promise;
+ await act(async () => { restoring = latest.restore(); });
+ assert.equal(latest.entry?.readiness, 'restoring');
+ await act(async () => { finishRestore(); await restoring; });
+ assert.deepEqual(writes, [{ sessionId: 'saved', model: 'selected' }]);
+ assert.equal(latest.entry?.readiness, 'ready');
+ assert.equal(latest.selection?.executorId, 'external');
+ } finally {
+ await act(async () => root.unmount());
+ for (const [key, descriptor] of originals) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
+ }
+});
+
test('a catalog change during failed discovery retries after the in-flight result settles', async () => {
const { document, window } = parseHTML('');
const values = { document, window, HTMLElement: window.HTMLElement, Node: window.Node, IS_REACT_ACT_ENVIRONMENT: true };
@@ -147,6 +195,8 @@ test('late draft discovery cannot replace the current target; existing tasks ins
assert.equal(latest.changing, true);
await act(async () => {
await assert.rejects(latest.select({ executorId: 'external', configuration: { model: 'other' } }), /pending/);
+ await assert.rejects(latest.restore(), /pending/);
+ assert.equal(latest.entry?.readiness, 'ready', 'a rejected restore is not shown as active');
serverModel = 'fast';
confirm({ ok: true, session: { executorConfig: { model: 'fast' } } });
await pending;
diff --git a/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts b/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts
index 52d71225504..587594f3d88 100644
--- a/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts
+++ b/apps/desktop/src/renderer/features/conversation/controller/use-executor-selection.ts
@@ -185,11 +185,29 @@ export function useExecutorSelection(input: {
}
};
const entry = catalog.find((candidate) => candidate.id === selection?.executorId);
+ const restore = async () => {
+ if (!sessionId || !executorId) throw new Error('Executor Session is unavailable');
+ if (inFlight.current === key) throw new Error('Executor configuration is pending');
+ const model = input.session?.executorConfig?.model ?? inspected?.currentModel;
+ if (!model) throw new Error('Executor model is unavailable');
+ setSnapshot((previous) =>
+ previous?.key === key
+ ? {
+ ...previous,
+ catalog: previous.catalog.map((entry) =>
+ entry.id === executorId ? { ...entry, readiness: 'restoring' } : entry,
+ ),
+ }
+ : previous,
+ );
+ await select({ executorId, configuration: { model } });
+ };
return {
selection,
catalog,
entry,
select,
+ restore,
refresh,
changing: changingKey === key,
loading: snapshot?.key !== key || snapshot.loading,
diff --git a/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts b/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts
index 489f17ed024..9be292d6207 100644
--- a/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts
+++ b/apps/desktop/src/renderer/features/conversation/model/executor-composer.ts
@@ -43,6 +43,7 @@ export function executorComposerProps(
loading: executor.loading,
error: executor.error,
onSelect: (selection) => executor.select(selection),
+ onRestore: () => executor.restore(),
onRetry: () => {
void executor.refresh();
},
diff --git a/docs/antigravity-acp-plugin-rebuild.md b/docs/antigravity-acp-plugin-rebuild.md
index 1c01a0db1af..edc33aa862e 100644
--- a/docs/antigravity-acp-plugin-rebuild.md
+++ b/docs/antigravity-acp-plugin-rebuild.md
@@ -69,8 +69,9 @@ policy, initial model configuration, and Antigravity question/failure recognitio
- 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.
+- Plugin-private external Session identity and write-ahead prompt state; a clean
+ completed task can resume the same ACP Session after a Host/Plugin restart,
+ while uncertain history stays readable without creating a replacement Session.
The Antigravity adapter owns:
@@ -83,6 +84,27 @@ The Host owns only executor visibility and binding, Maka Session/run identity, c
persistence, hosted form admission, and Plugin retirement. No ACP process, credential, or external
Session identifier crosses the Plugin boundary.
+## Restoring an existing task
+
+After a restart, open the Antigravity task and choose **Restore** in the model
+menu or the notice beside the composer. Maka starts the Agent only when you
+choose to restore or continue. The Agent must support ACP `session/resume`; a
+successful restore uses the original external Session and keeps the saved task
+and model. No earlier prompt is sent again.
+
+If restoration fails, the task history remains readable. Check the installed
+Agent and helper, Google sign-in, and network access in **Settings → External
+Agents**, then retry Restore. Maka will not create a new external Session for
+that task. An executable or helper change also requires a new task because the
+saved Session is bound to the previous installation.
+
+**History gap** means a prompt may have reached the Agent before Maka finished
+saving its terminal event. Maka inspects ACP load replay separately and does
+not append possibly duplicate output to the saved history or resend that
+prompt. Start a new task if you need to continue working. Tasks created before
+this restoration version have no saved external Session ID and remain
+history-only.
+
## Deliberately not ported
The rebuild does not carry forward the PR #5224 provider catalog protocol, external-Agent Session
diff --git a/docs/archive/antigravity-acp-pr2-acceptance.md b/docs/archive/antigravity-acp-pr2-acceptance.md
index 8e6680a5290..acfd6bdc9e6 100644
--- a/docs/archive/antigravity-acp-pr2-acceptance.md
+++ b/docs/archive/antigravity-acp-pr2-acceptance.md
@@ -287,7 +287,7 @@ 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
+Public CI and independent human approval passed before PR 2 merged as #5224 on 2026-09-23.
+Issue #5103's PR 2 checklist is 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.
+to PR 4. Neither is claimed by PR 2.
diff --git a/docs/archive/antigravity-acp-pr3-acceptance.md b/docs/archive/antigravity-acp-pr3-acceptance.md
new file mode 100644
index 00000000000..20e28fb1471
--- /dev/null
+++ b/docs/archive/antigravity-acp-pr3-acceptance.md
@@ -0,0 +1,148 @@
+
+
+# Antigravity ACP PR 3 acceptance
+
+PR 3 is tracked by [issue #5103](https://github.com/apache/maka/issues/5103).
+The results below are from a new probe, separate from the successful PR 2
+execution and earlier cross-process feasibility checks in
+[the PR 2 record](antigravity-acp-pr2-acceptance.md).
+
+## Real Agent prerequisite probe, 2026-09-24
+
+- Official macOS arm64 `agy_acp_server.par` **1.1.1** with its matching
+ `localharness_external`, ACP SDK **1.4.0**.
+- The probe used a temporary toy project with a randomly generated synthetic
+ token. An isolated temporary home copied only the existing OAuth token and
+ ACP settings into mode-restricted files, but stalled before `session/new`
+ completed. The method results below used the existing authenticated Agent
+ home with the toy project. No credentials, Session IDs, token values, or
+ project contents were recorded.
+- `initialize` returned protocol version 1, `loadSession: true`, and
+ `sessionCapabilities.resume`. `session/new` returned an external Session ID
+ and confirmed `gemini-3.7-flash-high`.
+- A fresh process accepted `session/resume` with the same ID and project
+ directory and returned the same model. A second independent process also
+ accepted `session/resume` for that ID. `session/load` accepted it and sent
+ two `user_message_chunk` notifications and an
+ `available_commands_update`. No successful Agent output existed to replay
+ in this run.
+- The first prompt and post-resume prompt each ended with `end_turn` but
+ emitted an Agent execution error: the model request returned HTTP 403,
+ reporting that the account was not eligible for Gemini Code Assist for
+ individuals in the current location. The temporary profile with and without
+ explicit HTTP(S) proxy variables stalled before Session creation. Direct access with
+ the machine's SOCKS proxy configuration failed because the bundled Python
+ lacked `python-socks`. Explicit local HTTP(S) proxy variables allowed
+ Session creation but produced the same model 403.
+- All probe-owned Agent process groups and temporary profiles were removed.
+
+This initial probe **did not pass the PR 3 prerequisite gate**. It confirms
+cross-process method acceptance, but cannot establish continued model context,
+successful replay shape, duplicate or reordered replay behavior, or a crash
+window where the Agent advanced beyond Maka's durable history. The 2026-09-12
+PR 2 feasibility probe observed one successful output chunk and tool replay;
+it did not send a post-resume prompt or establish reconciliation semantics.
+The later route-change probe below supplied the missing successful turns.
+
+## Post-login recheck, 2026-09-24
+
+After the user completed login in a locally built Maka, the official 1.1.1
+Agent was probed again with the authenticated home and a new temporary toy
+project. It negotiated the same resume/load capabilities, created a Session,
+and confirmed `gemini-3.7-flash-high`. After a synthetic-token prompt, a fresh
+process resumed the same Session ID and accepted a follow-up asking for that
+token. Both prompts emitted the same location-eligibility HTTP 403 Agent error,
+so the follow-up did not recall the token. A third process loaded the Session;
+it replayed two user-message chunks and an available-commands update, with no
+successful assistant or tool output to reconcile. The configured local HTTP(S)
+proxy's observed egress region was Singapore. All probe processes and the toy
+project were removed. Successful login therefore has not cleared the real-model
+prerequisite gate.
+
+## Route-change verification, 2026-09-24
+
+After the user changed the proxy exit, a new probe used the same official
+macOS arm64 1.1.1 server and matching helper, the user's authenticated Agent
+home, and a temporary toy project. Synthetic random values were used only to
+test context; no token values, credentials, external Session IDs, or private
+project content are recorded here. Intermittent HTTP 403 responses still
+occurred between successful requests, so the probe retried affected tool turns.
+
+- `initialize` again negotiated protocol 1, `loadSession: true`, and
+ `sessionCapabilities.resume`. `session/new` returned a Session ID and
+ `gemini-3.7-flash-high`.
+- A successful prompt gave the Agent one synthetic token. A new process used
+ `session/resume` with that same ID and project path, returned the same model,
+ and a follow-up answer recalled the exact token. The Session was not
+ replaced and the prompt was not resent.
+- A successful tool turn read a second synthetic token from a toy file. The
+ file was removed, then a new process used `session/load` and replayed user,
+ Agent, and tool updates. A follow-up recalled the file-only token after the
+ file was gone, proving that tool context survived the process change.
+- Two independent `session/load` calls produced the same update counts and
+ replay tool IDs for the completed history. A failed tool turn showed that
+ live tool IDs can differ from replayed IDs. Repeated identical user prompts
+ appeared as distinct replayed user chunks. Thus neither prompt text nor
+ live tool ID alone is a safe general deduplication key.
+- The probe terminated all child process groups and removed its toy project.
+
+These observations support `session/resume` for a fully committed Maka turn.
+For an interrupted turn, Maka captures `session/load` replay separately and
+reports a history gap. It does not append unaligned replay to the canonical
+conversation or submit another prompt. This is a conservative reconciliation
+decision because the official replay did not provide stable canonical event
+identities across every observed turn. The user can read the saved history and
+start a new task; the external Session ID is never silently replaced.
+
+## Built Plugin smoke, 2026-09-24
+
+The built production ACP Runtime and Antigravity adapter bundles were loaded
+with the official 1.1.1 executable/helper and the authenticated Agent home.
+A temporary toy task completed a synthetic-token prompt. The Runtime
+acknowledged that turn, disposed its process, and a fresh `AcpExecutor`
+instance reported `restorable` from the saved Plugin-private record. The
+explicit restore changed readiness to `ready`; a follow-up prompt completed
+and recalled the synthetic token. The test removed the toy project and did
+not print the token or external Session ID. This exercises the shipped
+Plugin code path in addition to the direct protocol probe. It does not stand
+in for a full Desktop UI restart test.
+
+## Implementation checks
+
+On the branch tested at `bd8661f3a` and rebased without conflicts to
+`b62ca805e` before opening the draft PR:
+
+- `npm run build`, `npm run typecheck`, `npm run lint`, and
+ `npm run format:check` passed.
+- Renderer architecture, locale hygiene, and the protocol compatibility epoch
+ guard passed; the epoch advanced from 183 to 184 for the new readiness values.
+- Every workspace test suite passed in a final serial run using
+ `node scripts/run-workspace-tests-parallel.mjs --concurrency=1`. Controlled
+ ACP tests include a forcibly killed, durably acknowledged child process
+ restored under the same external Session in a fresh process; uncertain load
+ replay remains outside the canonical turn.
+- An earlier serial run had one intermittent Runtime Host Goal handoff timing
+ assertion. The full Host workspace passed independently, that exact test
+ passed in isolation, and the final serial run passed. The failing test does
+ not touch ACP restoration.
+
+A full Desktop UI restart with a signed-in real Agent was not executed. The
+production Plugin bundle smoke, Desktop picker/controller tests, and the real
+Agent protocol probes cover the corresponding layers separately.
diff --git a/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts
index d78c76c1800..7b50b0f4d95 100644
--- a/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts
+++ b/packages/acp-executor-plugin/src/__tests__/acp-executor-plugin.test.ts
@@ -29,6 +29,7 @@ import {
type AcpAgentAdapter,
type AcpConnectionFactory,
type AcpConversationStateStore,
+ type AcpContinuityRecord,
} from '../index.js';
const adapter: AcpAgentAdapter<{ executable: string; model?: string }> = {
@@ -42,6 +43,451 @@ const adapter: AcpAgentAdapter<{ executable: string; model?: string }> = {
}),
};
+function durableState() {
+ const values = new Map();
+ let rejectCommit = false;
+ const state: AcpConversationStateStore = {
+ has: async (key) => values.has(key),
+ mark: async (key, cwd) => {
+ values.set(key, { version: 1, cwd });
+ },
+ read: async (key) => structuredClone(values.get(key)),
+ write: async (key, record) => {
+ if (rejectCommit && record.phase === 'committed') throw new Error('Commit unavailable');
+ values.set(key, structuredClone(record));
+ },
+ };
+ return {
+ state,
+ values,
+ rejectCommit: () => {
+ rejectCommit = true;
+ },
+ record: (key = 'session-a') => values.get(key) as AcpContinuityRecord,
+ };
+}
+
+test('durably acknowledged ACP Session resumes the same external ID without replay or session/new', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ state: storage.state,
+ createConnection: protocol.factory,
+ },
+ );
+ const first = make();
+ try {
+ assert.equal((await first.execute(request('first'), executorContext([]))).status, 'completed');
+ assert.equal(storage.record().phase, 'prompt_pending');
+ await first.acknowledgeExecution('session-a', 'turn-first');
+ assert.equal(storage.record().phase, 'committed');
+ assert.equal(storage.record().committedPrompts, 1);
+ await first.dispose();
+ const restored = make();
+ try {
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ 'restorable',
+ );
+ await restored.configureConversation(
+ { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'default' } },
+ new AbortController().signal,
+ );
+ assert.equal(protocol.sessions, 1);
+ assert.equal(protocol.resumes, 1);
+ assert.equal(protocol.loads, 0);
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ 'ready',
+ );
+ assert.equal(
+ (await restored.execute(request('second'), executorContext([]))).status,
+ 'completed',
+ );
+ await restored.acknowledgeExecution('session-a', 'turn-second');
+ assert.equal(storage.record().committedPrompts, 2);
+ assert.equal(protocol.sessions, 1);
+ } finally {
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('unacknowledged prompt loads replay as restoration and exposes history gap without a second prompt', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ state: storage.state,
+ createConnection: protocol.factory,
+ },
+ );
+ const first = make();
+ try {
+ assert.equal((await first.execute(request('first'), executorContext([]))).status, 'completed');
+ await first.dispose();
+ const restored = make();
+ const events: unknown[] = [];
+ try {
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ 'history_gap',
+ );
+ const result = await restored.execute(request('new-input'), executorContext(events));
+ assert.equal(result.status, 'failed');
+ if (result.status === 'failed') assert.equal(result.code, 'acp_history_gap');
+ assert.deepEqual(events, []);
+ assert.equal(protocol.loads, 1);
+ assert.equal(protocol.resumes, 0);
+ assert.equal(protocol.sessions, 1);
+ assert.equal(protocol.prompts, 1);
+ assert.equal(storage.record().phase, 'history_gap');
+ assert.equal(storage.record().gapEvidence?.replayedUserChunks, 1);
+ } finally {
+ await restored.dispose();
+ }
+ const reopened = make();
+ try {
+ const repeated = await reopened.execute(request('still-blocked'), executorContext(events));
+ assert.equal(repeated.status, 'failed');
+ if (repeated.status === 'failed') assert.equal(repeated.code, 'acp_history_gap');
+ assert.equal(protocol.loads, 1);
+ assert.equal(protocol.prompts, 1);
+ } finally {
+ await reopened.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('late restored-session updates stay quarantined while the history gap is persisted', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ let releaseGapWrite!: () => void;
+ const gapWriteBlocked = new Promise((resolve) => {
+ releaseGapWrite = resolve;
+ });
+ let gapWriteStarted!: () => void;
+ const gapWriteReached = new Promise((resolve) => {
+ gapWriteStarted = resolve;
+ });
+ const state: AcpConversationStateStore = {
+ ...storage.state,
+ write: async (key, record) => {
+ if (record.phase === 'history_gap') {
+ gapWriteStarted();
+ await gapWriteBlocked;
+ }
+ await storage.state.write!(key, record);
+ },
+ };
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ { state, createConnection: protocol.factory },
+ );
+ const first = make();
+ const blockedWrite = join(process.cwd(), 'node_modules/.maka-acp-restore-write');
+ try {
+ assert.equal((await first.execute(request('first'), executorContext([]))).status, 'completed');
+ await first.dispose();
+ const restored = make();
+ const events: unknown[] = [];
+ try {
+ const execution = restored.execute(request('unsent-new-turn'), executorContext(events));
+ await gapWriteReached;
+ protocol.notifyUpdate({
+ sessionUpdate: 'agent_message_chunk',
+ content: { type: 'text', text: 'LATE OLD EXTERNAL OUTPUT' },
+ });
+ protocol.notifyUpdate({
+ sessionUpdate: 'tool_call',
+ toolCallId: 'late-old-tool',
+ title: 'Historical tool',
+ });
+ assert.deepEqual(await protocol.requestPermission(), { outcome: { outcome: 'cancelled' } });
+ await Promise.all([
+ assert.rejects(protocol.readTextFile(join(process.cwd(), 'package.json')), {
+ code: 'acp_history_gap',
+ }),
+ assert.rejects(protocol.writeTextFile(blockedWrite, 'must not be written'), {
+ code: 'acp_history_gap',
+ }),
+ ]);
+ releaseGapWrite();
+ const result = await execution;
+ assert.equal(result.status, 'failed');
+ if (result.status === 'failed') assert.equal(result.code, 'acp_history_gap');
+ assert.deepEqual(events, []);
+ assert.equal(protocol.prompts, 1);
+ assert.equal(storage.record().phase, 'history_gap');
+ } finally {
+ releaseGapWrite();
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await rm(blockedWrite, { force: true });
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('failed restore permits explicit retry and never creates a replacement Session', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ state: storage.state,
+ createConnection: protocol.factory,
+ },
+ );
+ const first = make();
+ try {
+ await first.execute(request('first'), executorContext([]));
+ await first.acknowledgeExecution('session-a', 'turn-first');
+ await first.dispose();
+ const restored = make();
+ const input = {
+ conversationKey: 'session-a',
+ cwd: process.cwd(),
+ configuration: { model: 'default' },
+ };
+ try {
+ protocol.restoreFailure = true;
+ await assert.rejects(restored.configureConversation(input, new AbortController().signal));
+ assert.equal((await restored.inspectConversation(input)).readiness, 'restore_failed');
+ protocol.restoreFailure = false;
+ await restored.configureConversation(input, new AbortController().signal);
+ assert.equal((await restored.inspectConversation(input)).readiness, 'ready');
+ assert.equal(protocol.sessions, 1);
+ assert.equal(protocol.resumes, 1);
+ } finally {
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('missing restore capability, corrupted record and changed executable fail closed', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ state: storage.state,
+ createConnection: protocol.factory,
+ },
+ );
+ const first = make();
+ try {
+ await first.execute(request('first'), executorContext([]));
+ await first.acknowledgeExecution('session-a', 'turn-first');
+ await first.dispose();
+ const input = {
+ conversationKey: 'session-a',
+ cwd: process.cwd(),
+ configuration: { model: 'default' },
+ };
+ protocol.supportsRestore = false;
+ const unsupported = make();
+ await assert.rejects(unsupported.configureConversation(input, new AbortController().signal));
+ await unsupported.dispose();
+ protocol.supportsRestore = true;
+ await writeFile(fixture.executable, 'changed fixture');
+ const changed = make();
+ await assert.rejects(changed.configureConversation(input, new AbortController().signal));
+ assert.equal((await changed.inspectConversation(input)).readiness, 'restore_failed');
+ await writeFile(fixture.executable, 'fixture');
+ await changed.configureConversation(input, new AbortController().signal);
+ assert.equal((await changed.inspectConversation(input)).readiness, 'ready');
+ await changed.dispose();
+ storage.values.set('session-a', { version: 2, cwd: process.cwd(), sessionId: 'acp-session' });
+ const corrupt = make();
+ assert.equal((await corrupt.inspectConversation(input)).readiness, 'history_only');
+ assert.equal((await corrupt.execute(request('second'), executorContext([]))).status, 'failed');
+ await corrupt.dispose();
+ assert.equal(protocol.sessions, 1);
+ assert.equal(protocol.prompts, 1);
+ } finally {
+ await first.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('failed Plugin acknowledgement leaves a visible gap', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const executor = new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ state: storage.state,
+ createConnection: protocol.factory,
+ },
+ );
+ try {
+ await executor.execute(request('first'), executorContext([]));
+ storage.rejectCommit();
+ await assert.rejects(executor.acknowledgeExecution('session-a', 'turn-first'));
+ assert.equal(
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ 'history_gap',
+ );
+ assert.equal(storage.record().phase, 'prompt_pending');
+ } finally {
+ await executor.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('a terminal result abandoned before durable consumption becomes a history gap', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ const storage = durableState();
+ const executor = new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ { state: storage.state, createConnection: protocol.factory },
+ );
+ try {
+ assert.equal(
+ (await executor.execute(request('first'), executorContext([]))).status,
+ 'completed',
+ );
+ await executor.abandonExecution('session-a', 'turn-first');
+ assert.equal(storage.record().phase, 'history_gap');
+ assert.equal(
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ 'history_gap',
+ );
+ const blocked = await executor.execute(request('second'), executorContext([]));
+ assert.equal(blocked.status, 'failed');
+ if (blocked.status === 'failed') assert.equal(blocked.code, 'acp_history_gap');
+ } finally {
+ await executor.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('fresh configure initialization failure remains retryable', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ const storage = durableState();
+ let rejectRead = true;
+ const executor = new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ {
+ createConnection: protocol.factory,
+ state: {
+ ...storage.state,
+ read: async (key) => {
+ if (rejectRead) throw new Error('Storage unavailable');
+ return await storage.state.read!(key);
+ },
+ },
+ },
+ );
+ const input = {
+ conversationKey: 'session-a',
+ cwd: process.cwd(),
+ configuration: { model: 'default' },
+ };
+ try {
+ await assert.rejects(
+ executor.configureConversation(input, new AbortController().signal),
+ /Storage unavailable/u,
+ );
+ rejectRead = false;
+ assert.equal((await executor.inspectConversation(input)).readiness, 'ready');
+ await executor.configureConversation(input, new AbortController().signal);
+ assert.equal((await executor.inspectConversation(input)).readiness, 'ready');
+ assert.equal(protocol.sessions, 1);
+ } finally {
+ await executor.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
+test('concurrent restore retries share one replacement Session', async () => {
+ const fixture = await executableFixture();
+ const protocol = fakeProtocol();
+ protocol.supportsRestore = true;
+ const storage = durableState();
+ const make = () =>
+ new AcpExecutor(
+ adapter,
+ { executable: fixture.executable },
+ { state: storage.state, createConnection: protocol.factory },
+ );
+ const first = make();
+ try {
+ await first.execute(request('first'), executorContext([]));
+ await first.acknowledgeExecution('session-a', 'turn-first');
+ await first.dispose();
+ const restored = make();
+ const input = {
+ conversationKey: 'session-a',
+ cwd: process.cwd(),
+ configuration: { model: 'default' },
+ };
+ try {
+ protocol.restoreFailure = true;
+ await assert.rejects(restored.configureConversation(input, new AbortController().signal));
+ protocol.restoreFailure = false;
+ const retries = await Promise.allSettled([
+ restored.configureConversation(input, new AbortController().signal),
+ restored.configureConversation(input, new AbortController().signal),
+ ]);
+ assert.equal(retries.filter((result) => result.status === 'fulfilled').length, 1);
+ assert.equal(protocol.resumes, 1);
+ assert.equal((await restored.inspectConversation(input)).readiness, 'ready');
+ } finally {
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await rm(fixture.root, { recursive: true, force: true });
+ }
+});
+
test('runtime retains one ACP process and Session across prompts', async () => {
const fixture = await executableFixture();
const protocol = fakeProtocol();
@@ -385,6 +831,7 @@ for (const stopReason of [
]) {
test(`runtime drains cancellation and preserves ${stopReason}`, async () => {
const fixture = await executableFixture();
+ const storage = durableState();
let started!: () => void;
const ready = new Promise((resolve) => {
started = resolve;
@@ -429,7 +876,7 @@ for (const stopReason of [
const executor = new AcpExecutor(
adapter,
{ executable: fixture.executable },
- { createConnection: factory },
+ { createConnection: factory, state: storage.state },
);
const abort = new AbortController();
const execution = executor.execute(request('cancel'), executorContext([], abort.signal));
@@ -443,6 +890,16 @@ for (const stopReason of [
: { providerStopReason: stopReason }),
});
assert.equal(cancellations, 1);
+ assert.equal(storage.record().phase, 'prompt_pending');
+ await executor.acknowledgeExecution('session-a', 'turn-cancel');
+ const uncertain = ['request_error', 'process_crash'].includes(stopReason);
+ assert.equal(storage.record().phase, uncertain ? 'prompt_pending' : 'committed');
+ await executor.disposeConversation('session-a');
+ assert.equal(
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
+ .readiness,
+ uncertain ? 'restore_failed' : 'restorable',
+ );
} finally {
await executor.dispose();
await rm(fixture.root, { recursive: true, force: true });
@@ -532,26 +989,26 @@ test('discovery shares a disposable probe, does not mark a task, and first promp
const before = protocol.connections;
const status = await executor.inspectConversation({
conversationKey: 'session-a',
- cwd: fixture.root,
+ cwd: process.cwd(),
});
assert.equal(status.currentModel, 'fast');
assert.equal(protocol.connections, before);
await executor.configureConversation(
- { conversationKey: 'session-a', cwd: fixture.root, configuration: { model: 'default' } },
+ { conversationKey: 'session-a', cwd: process.cwd(), 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' } },
+ { conversationKey: 'session-a', cwd: process.cwd(), configuration: { model: 'removed' } },
new AbortController().signal,
),
/unavailable/u,
);
assert.equal(protocol.selectedModel, 'default');
assert.equal(
- (await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
.readiness,
'ready',
);
@@ -586,14 +1043,14 @@ for (const failure of ['response_lost', 'timeout'] as const) {
timeout = setTimeout(() => abort.abort(new DOMException('Timed out', 'TimeoutError')), 50);
await assert.rejects(
executor.configureConversation(
- { conversationKey: 'session-a', cwd: fixture.root, configuration: { model: 'fast' } },
+ { conversationKey: 'session-a', cwd: process.cwd(), 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 }))
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
.readiness,
'history_only',
);
@@ -706,6 +1163,14 @@ function fakeProtocol(): {
configurationFailure?: 'response_lost' | 'unconfirmed' | 'timeout' | 'once';
sessionCreationFailure?: 'once';
promptModels: string[];
+ supportsRestore: boolean;
+ restoreFailure?: boolean;
+ resumes: number;
+ loads: number;
+ notifyUpdate(update: unknown): void;
+ requestPermission(): Promise;
+ readTextFile(path: string): Promise;
+ writeTextFile(path: string, content: string): Promise;
notifyConfiguration(model: string): void;
} {
const fixture = {
@@ -722,6 +1187,14 @@ function fakeProtocol(): {
| undefined,
sessionCreationFailure: undefined as 'once' | undefined,
promptModels: [] as string[],
+ supportsRestore: false,
+ restoreFailure: false,
+ resumes: 0,
+ loads: 0,
+ notifyUpdate: (_update: unknown): void => {},
+ requestPermission: async (): Promise => undefined,
+ readTextFile: async (_path: string): Promise => undefined,
+ writeTextFile: async (_path: string, _content: string): Promise => undefined,
notifyConfiguration: (_model: string): void => {},
factory: undefined as unknown as AcpConnectionFactory,
};
@@ -740,6 +1213,27 @@ function fakeProtocol(): {
},
} as unknown as ClientApp;
input.configureClient(app);
+ fixture.notifyUpdate = (update) => {
+ notifications.get(methods.client.session.update)?.({
+ params: { sessionId: 'acp-session', update } as never,
+ });
+ };
+ fixture.requestPermission = async () =>
+ await requests.get(methods.client.session.requestPermission)?.({
+ params: {
+ sessionId: 'acp-session',
+ toolCall: { toolCallId: 'late-old-tool', title: 'Allow edit?' },
+ options: [{ optionId: 'allow_once', name: 'Allow once', kind: 'allow_once' }],
+ } as never,
+ });
+ fixture.readTextFile = async (path) =>
+ await requests.get(methods.client.fs.readTextFile)?.({
+ params: { sessionId: 'acp-session', path } as never,
+ });
+ fixture.writeTextFile = async (path, content) =>
+ await requests.get(methods.client.fs.writeTextFile)?.({
+ params: { sessionId: 'acp-session', path, content } as never,
+ });
fixture.notifyConfiguration = (model) => {
notifications.get(methods.client.session.update)?.({
params: {
@@ -769,7 +1263,44 @@ function fakeProtocol(): {
params: Record,
options?: { cancellationSignal?: AbortSignal },
) => {
- if (method === methods.agent.initialize) return { protocolVersion: 1 };
+ if (method === methods.agent.initialize)
+ return {
+ protocolVersion: 1,
+ agentCapabilities: fixture.supportsRestore
+ ? { loadSession: true, sessionCapabilities: { resume: {} } }
+ : {},
+ };
+ if (method === methods.agent.session.resume || method === methods.agent.session.load) {
+ assert.equal(params.sessionId, 'acp-session');
+ assert.equal(params.cwd, process.cwd());
+ if (fixture.restoreFailure) throw new Error('Session unavailable');
+ if (method === methods.agent.session.resume) fixture.resumes += 1;
+ else {
+ fixture.loads += 1;
+ for (const update of [
+ { sessionUpdate: 'user_message_chunk', content: { type: 'text', text: 'first' } },
+ { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'replay' } },
+ { sessionUpdate: 'tool_call', toolCallId: 'replay-tool', title: 'Historical tool' },
+ ])
+ notifications.get(methods.client.session.update)?.({
+ params: { sessionId: 'acp-session', update } as never,
+ });
+ }
+ return {
+ configOptions: [
+ {
+ type: 'select',
+ id: 'model',
+ name: 'Model',
+ currentValue: fixture.selectedModel ?? 'default',
+ options: [
+ { value: 'default', name: 'Default' },
+ { value: 'fast', name: 'Fast' },
+ ],
+ },
+ ],
+ };
+ }
if (method === methods.agent.session.new) {
fixture.sessions += 1;
if (fixture.sessionCreationFailure === 'once') {
@@ -931,7 +1462,7 @@ for (const failure of ['once', 'unconfirmed'] as const)
);
const input = {
conversationKey: 'session-a',
- cwd: fixture.root,
+ cwd: process.cwd(),
configuration: { model: 'fast' },
};
try {
@@ -970,7 +1501,7 @@ test('idle Agent configuration notifications update the inspected model without
);
protocol.notifyConfiguration('fast');
assert.equal(
- (await executor.inspectConversation({ conversationKey: 'session-a', cwd: fixture.root }))
+ (await executor.inspectConversation({ conversationKey: 'session-a', cwd: process.cwd() }))
.currentModel,
'fast',
);
diff --git a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts
index 0dad5beb094..aa3e2902ea1 100644
--- a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts
+++ b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts
@@ -22,7 +22,7 @@ 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 { AcpExecutor, type AcpAgentAdapter, type AcpContinuityRecord } from '../index.js';
import { readWorkspaceTextFile } from '../acp-filesystem.js';
import { Context } from '@maka/runtime/plugin-kernel';
import { PluginExecutorService } from '@maka/runtime/plugin-executor-service';
@@ -36,21 +36,40 @@ import type {
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 fs=require('node:fs');
+const pending=new Map(); let seq=0, turn=0, cwd, promptId, history=[];
+const historyPath=()=>cwd+'/.acp-fixture-history.json';
+const persist=()=>fs.writeFileSync(historyPath(),JSON.stringify(history));
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 update=(update)=>{history.push(update);persist();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==='initialize')return respond(m.id,{protocolVersion:1,agentCapabilities:{loadSession:true,sessionCapabilities:{resume:{}}}});
+ if(m.method==='session/new'){
+ cwd=m.params.cwd;
+ if(fs.existsSync(historyPath()))return send({id:m.id,error:{code:-32000,message:'Session already exists'}});
+ history=[];persist();return respond(m.id,{sessionId:'fixture',configOptions:[]});
+ }
+ if(m.method==='session/resume'||m.method==='session/load'){
+ cwd=m.params.cwd;
+ if(m.params.sessionId!=='fixture'||!fs.existsSync(historyPath()))
+ return send({id:m.id,error:{code:-32000,message:'Unknown Session'}});
+ history=JSON.parse(fs.readFileSync(historyPath(),'utf8'));
+ turn=history.filter(item=>item.sessionUpdate==='user_message_chunk').length;
+ if(m.method==='session/load')for(const item of history)
+ send({method:'session/update',params:{sessionId:'fixture',update:item}});
+ return respond(m.id,{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;
+ update({sessionUpdate:'user_message_chunk',content:{type:'text',text:value}});
if(value==='crash'){process.exit(3);return;}
+ if(value==='refusal'){respond(m.id,{stopReason:'refusal'});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;}
@@ -123,6 +142,7 @@ async function fixture() {
});
return {
root,
+ script,
executor,
request,
context,
@@ -167,6 +187,204 @@ test('real stdio retains a conversation and returns original question option ids
}
});
+test('real stdio restores the same Session in a new process after durable acknowledgement', async () => {
+ const f = await fixture();
+ const values = new Map();
+ const state = {
+ has: async (key: string) => values.has(key),
+ mark: async (key: string, cwd: string) => {
+ values.set(key, { version: 1, cwd });
+ },
+ read: async (key: string) => values.get(key),
+ write: async (key: string, record: unknown) => {
+ values.set(key, record);
+ },
+ };
+ const adapter: AcpAgentAdapter = {
+ id: 'stdio-restore-fixture',
+ displayName: 'Fixture',
+ configure: () => ({ launch: { executable: process.execPath, args: [f.script] } }),
+ permissionKind: () => 'question',
+ };
+ const make = () => new AcpExecutor(adapter, {}, { state });
+ const first = make();
+ try {
+ const initial = await first.execute(f.request('one'), f.context());
+ assert.equal(initial.status, 'completed');
+ if (initial.status !== 'completed') throw new Error('Expected first completion');
+ await first.acknowledgeExecution('task', 'one');
+ const firstPid = JSON.parse(initial.text).pid as number;
+ assert.notEqual(firstPid, process.pid);
+ process.kill(firstPid, 'SIGKILL');
+ let readiness: string | undefined;
+ for (let attempt = 0; attempt < 60; attempt++) {
+ readiness = (await first.inspectConversation({ conversationKey: 'task', cwd: f.root }))
+ .readiness;
+ if (readiness === 'restorable') break;
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ }
+ assert.equal(readiness, 'restorable');
+ await first.dispose();
+ const restored = make();
+ try {
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
+ 'restorable',
+ );
+ const later = await restored.execute(f.request('two'), f.context());
+ assert.equal(later.status, 'completed');
+ if (later.status !== 'completed') throw new Error('Expected restored completion');
+ assert.notEqual(JSON.parse(initial.text).pid, JSON.parse(later.text).pid);
+ assert.equal(JSON.parse(later.text).turn, 2);
+ await restored.acknowledgeExecution('task', 'two');
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
+ 'ready',
+ );
+ } finally {
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ await f.dispose();
+ }
+});
+
+for (const prompt of ['wait', 'refusal', 'ignore-cancel', 'crash']) {
+ test(`real stdio checkpoint after ${prompt} preserves safe restart behavior`, async () => {
+ const f = await fixture();
+ const values = new Map();
+ const state = {
+ has: async (key: string) => values.has(key),
+ mark: async () => {
+ throw new Error('Expected versioned storage');
+ },
+ read: async (key: string) => values.get(key),
+ write: async (key: string, record: AcpContinuityRecord) => {
+ values.set(key, record);
+ },
+ };
+ const adapter: AcpAgentAdapter = {
+ id: 'stdio-checkpoint-fixture',
+ displayName: 'Fixture',
+ configure: () => ({ launch: { executable: process.execPath, args: [f.script] } }),
+ permissionKind: () => 'question',
+ };
+ const make = () => new AcpExecutor(adapter, {}, { state });
+ const first = make();
+ const root = new Context();
+ const service = new PluginExecutorService(root);
+ root
+ .extend({
+ maka: { rootId: 'profile', packageId: 'fixture', entryId: 'stdio', generation: 1 },
+ })
+ .executors.register(first);
+ const backend = new PluginExecutorBackend({
+ sessionId: 'task',
+ cwd: f.root,
+ binding: service.bind('task', first.id),
+ });
+ const restored = make();
+ try {
+ let firstPid: number | undefined;
+ for await (const event of backend.send({ turnId: 'one', text: 'one' })) {
+ if (event.type === 'text_complete') firstPid = JSON.parse(event.text).pid;
+ }
+ assert.equal(values.get('task')?.phase, 'committed');
+ const terminalEvents: string[] = [];
+ for await (const event of backend.send({ turnId: 'stopped', text: prompt })) {
+ terminalEvents.push(event.type);
+ if (event.type === 'text_delta') await backend.stop('user_stop');
+ if (event.type === 'complete')
+ assert.equal(values.get('task')?.phase, 'prompt_pending', 'delivery alone cannot commit');
+ }
+ assert.equal(terminalEvents.at(-1), 'complete');
+ assert.ok(terminalEvents.includes(['crash', 'refusal'].includes(prompt) ? 'error' : 'abort'));
+ const settled = prompt === 'wait' || prompt === 'refusal';
+ assert.equal(values.get('task')?.phase, settled ? 'committed' : 'prompt_pending');
+ assert.equal(values.get('task')?.committedPrompts, settled ? 2 : 1);
+ await first.dispose();
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
+ settled ? 'restorable' : 'history_gap',
+ );
+ const events: PluginExecutorOutputEvent[] = [];
+ const result = await restored.execute(
+ f.request('after'),
+ f.context(undefined, (event) => events.push(event)),
+ );
+ if (settled) {
+ assert.equal(result.status, 'completed');
+ if (result.status !== 'completed') throw new Error('Expected restored completion');
+ assert.equal(JSON.parse(result.text).turn, 3);
+ assert.notEqual(JSON.parse(result.text).pid, firstPid);
+ await restored.acknowledgeExecution('task', 'after');
+ assert.equal(values.get('task')?.committedPrompts, 3);
+ } else {
+ assert.equal(result.status, 'failed');
+ if (result.status !== 'failed') throw new Error('Expected history gap');
+ assert.equal(result.code, 'acp_history_gap');
+ assert.deepEqual(events, [], 'uncertain replay must not reach canonical history');
+ }
+ } finally {
+ await backend.dispose();
+ await root.fiber.dispose();
+ await first.dispose();
+ await restored.dispose();
+ await f.dispose();
+ }
+ });
+}
+
+test('real stdio load replay stays separate when the Agent may be ahead of durable history', async () => {
+ const f = await fixture();
+ const values = new Map();
+ const state = {
+ has: async (key: string) => values.has(key),
+ mark: async (key: string, cwd: string) => {
+ values.set(key, { version: 1, cwd });
+ },
+ read: async (key: string) => values.get(key),
+ write: async (key: string, record: unknown) => {
+ values.set(key, record);
+ },
+ };
+ const adapter: AcpAgentAdapter = {
+ id: 'stdio-restore-fixture',
+ displayName: 'Fixture',
+ configure: () => ({ launch: { executable: process.execPath, args: [f.script] } }),
+ permissionKind: () => 'question',
+ };
+ const make = () => new AcpExecutor(adapter, {}, { state });
+ const first = make();
+ try {
+ assert.equal((await first.execute(f.request('one'), f.context())).status, 'completed');
+ // Simulate process/Host loss after the Agent finished but before Maka's
+ // terminal event was acknowledged to the Plugin.
+ await first.dispose();
+ const restored = make();
+ const projected: PluginExecutorOutputEvent[] = [];
+ try {
+ const result = await restored.execute(
+ f.request('new-user-input'),
+ f.context(undefined, (event) => projected.push(event)),
+ );
+ assert.equal(result.status, 'failed');
+ if (result.status === 'failed') assert.equal(result.code, 'acp_history_gap');
+ assert.deepEqual(projected, []);
+ assert.equal(
+ (await restored.inspectConversation({ conversationKey: 'task', cwd: f.root })).readiness,
+ 'history_gap',
+ );
+ } finally {
+ await restored.dispose();
+ }
+ } finally {
+ await first.dispose();
+ 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-'));
diff --git a/packages/acp-executor-plugin/src/index.ts b/packages/acp-executor-plugin/src/index.ts
index e323d6f5ee1..af24887252a 100644
--- a/packages/acp-executor-plugin/src/index.ts
+++ b/packages/acp-executor-plugin/src/index.ts
@@ -18,7 +18,7 @@
*/
import { createHash } from 'node:crypto';
-import { constants } from 'node:fs';
+import { constants, createReadStream } 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';
@@ -105,6 +105,25 @@ export type AcpConnectionFactory = (input: AcpConnectionFactoryInput) => AcpConn
export interface AcpConversationStateStore {
has(conversationKey: string, cwd: string): Promise;
mark(conversationKey: string, cwd: string): Promise;
+ /** Versioned Plugin-private state. Older test providers may expose only v1 markers. */
+ read?(conversationKey: string): Promise;
+ write?(conversationKey: string, state: AcpContinuityRecord): Promise;
+}
+
+export interface AcpContinuityRecord {
+ readonly version: 2;
+ readonly cwd: string;
+ /** Digest of the adapter and exact executable/helper bytes; never sent to the Host. */
+ readonly binding: string;
+ readonly phase: 'reserved' | 'established' | 'prompt_pending' | 'committed' | 'history_gap';
+ readonly committedPrompts: number;
+ readonly sessionId?: string;
+ readonly pendingTurnId?: string;
+ readonly confirmedModel?: string;
+ readonly gapEvidence?: {
+ readonly replayedUpdates: number;
+ readonly replayedUserChunks: number;
+ };
}
interface ActivePrompt {
@@ -135,6 +154,12 @@ interface RetainedSession {
owner?: AcpConnectionOwner;
connection?: ClientConnection;
acpSessionId?: string;
+ record?: AcpContinuityRecord;
+ restoring?: boolean;
+ replay?: { updates: number; userChunks: number };
+ restoreFailed?: boolean;
+ historyGap?: boolean;
+ awaitingAck?: string;
continuityReserved?: boolean;
configOptions: readonly SessionConfigOption[];
initialization?: Promise;
@@ -215,27 +240,62 @@ export class AcpExecutor implements PluginExecutorProvider {
}): Promise {
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);
+ if (session && session.cwd !== resolve(input.cwd))
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
+ if (session?.restoring)
+ return this.#catalogEntry('restoring', session.configOptions, input.configuration?.model);
+ if (session?.restoreFailed)
+ return this.#catalogEntry(
+ 'restore_failed',
+ session.configOptions,
+ input.configuration?.model,
+ );
+ if (session?.historyGap)
+ return this.#catalogEntry('history_gap', session.configOptions, input.configuration?.model);
+ if (session && !session.lost)
+ return this.#catalogEntry('ready', session.configOptions, input.configuration?.model);
+ if (session?.lost && !session.record)
+ return this.#catalogEntry('history_only', session.configOptions, input.configuration?.model);
+ const stored = this.#state?.read
+ ? decodeContinuity(await this.#state.read(input.conversationKey))
+ : (await this.#state?.has(input.conversationKey, input.cwd))
+ ? 'legacy'
+ : undefined;
+ if (!stored) return this.#catalogEntry('ready', [], input.configuration?.model);
+ if (stored === 'invalid' || stored === 'legacy' || stored.cwd !== resolve(input.cwd))
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
+ if (stored.phase === 'reserved')
+ return this.#catalogEntry('history_only', [], input.configuration?.model);
+ return this.#catalogEntry(
+ stored.phase === 'prompt_pending' || stored.phase === 'history_gap'
+ ? 'history_gap'
+ : 'restorable',
+ [],
+ input.configuration?.model ?? stored.confirmedModel,
+ );
}
async configureConversation(
input: { conversationKey: string; cwd: string; configuration?: ExecutorConfiguration },
signal: AbortSignal,
): Promise {
- 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');
+ if (!input.configuration?.model) throw new Error('ACP model change is unavailable');
+ const session = await this.#session(input);
+ if (session.active || session.configuring) throw new Error('ACP model change is unavailable');
session.configuring = true;
+ const wasConnected = !!session.connection;
try {
+ await this.#ensureInitialized(session, signal);
await this.#applyInitialConfig(session, { model: input.configuration.model }, signal, true);
+ } catch (error) {
+ if (!wasConnected || session.lost) {
+ await this.#lose(session);
+ if (session.record?.sessionId && !session.historyGap) session.restoreFailed = true;
+ const retryable = !session.acpSessionId && !session.continuityReserved && !session.record;
+ if (retryable && this.#sessions.get(session.conversationKey) === session)
+ this.#sessions.delete(session.conversationKey);
+ }
+ throw error;
} finally {
session.configuring = false;
}
@@ -255,12 +315,17 @@ export class AcpExecutor implements PluginExecutorProvider {
? model.options
.flatMap((entry) => ('options' in entry ? entry.options : [entry]))
.map((entry) => ({ id: entry.value, name: entry.name }))
- : [];
+ : (this.#catalog?.models ?? []);
return {
id: this.id,
displayName: this.displayName,
readiness,
- ...(this.#adapter.describeModels?.(models) ?? { models }),
+ ...(model?.type === 'select'
+ ? (this.#adapter.describeModels?.(models) ?? { models })
+ : {
+ models,
+ ...(this.#catalog?.modelGroups ? { modelGroups: this.#catalog.modelGroups } : {}),
+ }),
...(model?.type === 'select'
? { currentModel: model.currentValue }
: selected
@@ -294,11 +359,11 @@ export class AcpExecutor implements PluginExecutorProvider {
if (model) request = { ...request, configuration: { model } };
let session: RetainedSession;
try {
- session = this.#session(request);
+ session = await this.#session(request);
} catch (error) {
return failure(safeErrorMessage(error), errorCode(error));
}
- if (session.active || session.configuring)
+ if (session.active || session.configuring || session.awaitingAck)
return failure(`${this.displayName} ACP Session is busy`, 'acp_busy', true);
const active: ActivePrompt = { context, tools: new Map(), text: '' };
session.active = active;
@@ -311,12 +376,17 @@ export class AcpExecutor implements PluginExecutorProvider {
{ model: request.configuration.model },
context.signal,
);
+ await this.#beginPrompt(session, request.turnId);
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' };
+ // A settled prompt can be checkpointed even when it was cancelled or failed.
+ // Timeouts and transport failures never reach this point, and the Runtime
+ // must still durably consume the terminal event before acknowledging it.
+ if (session.record) session.awaitingAck = request.turnId;
if (context.signal.aborted || response.stopReason === 'cancelled') {
return { status: 'cancelled', providerStopReason: response.stopReason };
}
@@ -331,13 +401,16 @@ export class AcpExecutor implements PluginExecutorProvider {
}
return { status: 'completed', text: active.text };
} catch (error) {
+ if (errorCode(error) === 'acp_history_gap') session.historyGap = true;
await this.#lose(session);
+ if (session.record?.sessionId && !session.historyGap) session.restoreFailed = true;
// 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';
+ errorCode(error) !== 'acp_history_only' &&
+ errorCode(error) !== 'acp_history_gap';
if (retryable && this.#sessions.get(session.conversationKey) === session)
this.#sessions.delete(session.conversationKey);
if (context.signal.aborted) {
@@ -373,18 +446,27 @@ export class AcpExecutor implements PluginExecutorProvider {
if (failures.length) throw new AggregateError(failures, 'ACP process cleanup failed');
}
- #session(request: Readonly): RetainedSession {
+ async #session(request: {
+ readonly conversationKey: string;
+ readonly cwd: string;
+ readonly configuration?: ExecutorConfiguration;
+ }): Promise {
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)
+ if (!existing.lost) return existing;
+ if (existing.active || existing.configuring)
+ throw new AcpRuntimeError('ACP Session is busy', 'acp_busy');
+ if (!existing.record)
throw new AcpRuntimeError(
'ACP conversation is history-only because its external process was lost',
'acp_history_only',
);
- return existing;
+ await existing.loss;
+ if (this.#sessions.get(request.conversationKey) !== existing) return this.#session(request);
+ this.#sessions.delete(request.conversationKey);
}
const created: RetainedSession = {
conversationKey: request.conversationKey,
@@ -405,21 +487,54 @@ export class AcpExecutor implements PluginExecutorProvider {
await initialization;
} catch (error) {
session.initialization = undefined;
+ session.restoring = false;
+ session.replay = undefined;
throw error;
}
}
async #initialize(session: RetainedSession, signal: AbortSignal, probe = false): Promise {
- if (!probe && (await this.#state?.has(session.conversationKey, session.cwd))) {
+ const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS);
+ const startupSignal = AbortSignal.any([signal, timeout]);
+ startupSignal.throwIfAborted();
+ const launch = this.#configured.launch;
+ const stored = probe
+ ? undefined
+ : this.#state?.read
+ ? decodeContinuity(await this.#state.read(session.conversationKey))
+ : (await this.#state?.has(session.conversationKey, session.cwd))
+ ? 'legacy'
+ : undefined;
+ if (stored === 'legacy')
throw new AcpRuntimeError(
'ACP conversation is history-only after the Plugin or Host was restarted',
'acp_history_only',
);
+ if (
+ stored === 'invalid' ||
+ (stored && stored.cwd !== session.cwd) ||
+ stored?.phase === 'reserved'
+ )
+ throw new AcpRuntimeError(
+ 'ACP conversation has no recoverable external Session identity',
+ 'acp_history_only',
+ );
+ if (stored?.phase === 'history_gap')
+ throw new AcpRuntimeError(
+ 'External Session history cannot be aligned with saved conversation events',
+ 'acp_history_gap',
+ );
+ if (stored) {
+ session.record = stored;
+ session.acpSessionId = stored.sessionId;
+ session.restoring = true;
}
- const timeout = AbortSignal.timeout(INITIALIZE_TIMEOUT_MS);
- const startupSignal = AbortSignal.any([signal, timeout]);
- startupSignal.throwIfAborted();
- const launch = this.#configured.launch;
+ const binding =
+ !probe && this.#state?.write
+ ? await launchBinding(this.id, launch, startupSignal)
+ : undefined;
+ if (stored && stored.binding !== binding)
+ throw new AcpRuntimeError('ACP Agent installation changed', 'acp_restore_identity_changed');
const executable = await checkedExecutable(launch.executable);
await Promise.all((launch.requiredExecutables ?? []).map(checkedExecutable));
const owner = this.#createConnection({
@@ -449,11 +564,70 @@ export class AcpExecutor implements PluginExecutorProvider {
owner.failed,
]);
if (initialized.protocolVersion !== 1) throw new Error('Unsupported ACP protocol version');
+ if (stored) {
+ session.replay = { updates: 0, userChunks: 0 };
+ const pending = stored.phase === 'prompt_pending';
+ const supported = pending
+ ? initialized.agentCapabilities?.loadSession
+ : initialized.agentCapabilities?.sessionCapabilities?.resume;
+ if (!supported)
+ throw new AcpRuntimeError(
+ 'ACP Agent cannot restore this Session',
+ 'acp_restore_unsupported',
+ );
+ const restored = await Promise.race([
+ owner.connection.agent.request(
+ pending ? methods.agent.session.load : methods.agent.session.resume,
+ { sessionId: stored.sessionId!, cwd: session.cwd, mcpServers: [] },
+ { cancellationSignal: startupSignal },
+ ),
+ owner.failed,
+ ]);
+ session.configOptions = restored.configOptions ?? [];
+ const restoredModel = currentAcpModel(session.configOptions);
+ if (restoredModel) await this.#persistConfirmedModel(session, restoredModel);
+ session.restoring = false;
+ if (pending) {
+ // The Agent may have progressed beyond Maka's last durable event. Replay
+ // identities are not sufficient to fabricate canonical history: failed
+ // turns can change tool IDs between live output and load. Keep every
+ // replay notification separate and expose an explicit history gap.
+ session.historyGap = true;
+ session.record = {
+ ...session.record!,
+ phase: 'history_gap',
+ pendingTurnId: undefined,
+ gapEvidence: {
+ replayedUpdates: session.replay?.updates ?? 0,
+ replayedUserChunks: session.replay?.userChunks ?? 0,
+ },
+ };
+ await this.#state?.write?.(session.conversationKey, session.record);
+ session.replay = undefined;
+ throw new AcpRuntimeError(
+ 'External Session was restored, but conversation history may have a gap',
+ 'acp_history_gap',
+ );
+ }
+ session.replay = undefined;
+ return;
+ }
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);
+ if (this.#state?.write && binding) {
+ session.record = {
+ version: 2,
+ cwd: session.cwd,
+ binding,
+ phase: 'reserved',
+ committedPrompts: 0,
+ };
+ await this.#state.write(session.conversationKey, session.record);
+ } else {
+ await this.#state?.mark(session.conversationKey, session.cwd);
+ }
session.continuityReserved = true;
}
const created = await Promise.race([
@@ -466,6 +640,17 @@ export class AcpExecutor implements PluginExecutorProvider {
]);
session.acpSessionId = created.sessionId;
session.configOptions = created.configOptions ?? [];
+ if (session.record) {
+ const established: AcpContinuityRecord = {
+ ...session.record,
+ phase: 'established',
+ sessionId: created.sessionId,
+ };
+ await this.#state?.write?.(session.conversationKey, established);
+ session.record = established;
+ const createdModel = currentAcpModel(session.configOptions);
+ if (createdModel) await this.#persistConfirmedModel(session, createdModel);
+ }
if (!probe) {
await this.#applyInitialConfig(
session,
@@ -477,6 +662,68 @@ export class AcpExecutor implements PluginExecutorProvider {
}
}
+ async #beginPrompt(session: RetainedSession, turnId: string): Promise {
+ if (!session.record) return;
+ const pending: AcpContinuityRecord = {
+ ...session.record,
+ phase: 'prompt_pending',
+ pendingTurnId: turnId,
+ };
+ await this.#state?.write?.(session.conversationKey, pending);
+ session.record = pending;
+ }
+
+ /** Called only after the Runtime Kernel has durably accepted the terminal event. */
+ async acknowledgeExecution(conversationKey: string, turnId: string): Promise {
+ const session = this.#sessions.get(conversationKey);
+ if (
+ !session?.record ||
+ session.awaitingAck !== turnId ||
+ session.record.phase !== 'prompt_pending' ||
+ session.record.pendingTurnId !== turnId
+ )
+ return;
+ const committed: AcpContinuityRecord = {
+ ...session.record,
+ phase: 'committed',
+ committedPrompts: session.record.committedPrompts + 1,
+ pendingTurnId: undefined,
+ };
+ try {
+ await this.#state?.write?.(conversationKey, committed);
+ } catch (error) {
+ session.historyGap = true;
+ await this.#lose(session);
+ throw error;
+ }
+ session.record = committed;
+ session.awaitingAck = undefined;
+ }
+
+ /** The external turn settled, but Maka did not durably consume its terminal event. */
+ async abandonExecution(conversationKey: string, turnId: string): Promise {
+ const session = this.#sessions.get(conversationKey);
+ if (
+ !session?.record ||
+ session.awaitingAck !== turnId ||
+ session.record.phase !== 'prompt_pending' ||
+ session.record.pendingTurnId !== turnId
+ )
+ return;
+ session.historyGap = true;
+ session.awaitingAck = undefined;
+ session.record = {
+ ...session.record,
+ phase: 'history_gap',
+ pendingTurnId: undefined,
+ };
+ try {
+ await this.#state?.write?.(conversationKey, session.record);
+ } finally {
+ await this.#lose(session);
+ }
+ }
+
async #applyInitialConfig(
session: RetainedSession,
values: Readonly>,
@@ -501,7 +748,10 @@ export class AcpExecutor implements PluginExecutorProvider {
`ACP configuration value is unavailable: ${key}`,
'acp_config_invalid',
);
- if (option.currentValue === value) continue;
+ if (option.currentValue === value) {
+ if (key === 'model') await this.#persistConfirmedModel(session, value);
+ continue;
+ }
signal.throwIfAborted();
try {
const updated = await session.connection!.agent.request(
@@ -516,6 +766,7 @@ export class AcpExecutor implements PluginExecutorProvider {
'acp_config_unconfirmed',
);
session.configOptions = updated.configOptions;
+ if (key === 'model') await this.#persistConfirmedModel(session, value);
} catch (error) {
// A rejected/unconfirmed mutation can have reached the Agent. Restore the
// previous real ID and require an acknowledgement before permitting retry.
@@ -536,6 +787,7 @@ export class AcpExecutor implements PluginExecutorProvider {
!session.lost
) {
session.configOptions = rollback.configOptions;
+ if (key === 'model') await this.#persistConfirmedModel(session, option.currentValue);
restored = true;
}
} catch {
@@ -548,19 +800,26 @@ export class AcpExecutor implements PluginExecutorProvider {
}
}
+ async #persistConfirmedModel(session: RetainedSession, model: string): Promise {
+ if (!session.record || session.record.confirmedModel === model) return;
+ const record = { ...session.record, confirmedModel: model };
+ await this.#state?.write?.(session.conversationKey, record);
+ session.record = record;
+ }
+
#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);
+ this.#assertWorkspaceAccess(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);
+ this.#assertWorkspaceAccess(session, params.sessionId);
await writeWorkspaceTextFile(session.cwd, params.path, params.content);
return {};
})
@@ -571,6 +830,8 @@ export class AcpExecutor implements PluginExecutorProvider {
async #requestPermission(session: RetainedSession, request: RequestPermissionRequest) {
this.#assertSession(session, request.sessionId);
+ if (session.restoring || session.historyGap || session.lost)
+ return { outcome: { outcome: 'cancelled' as const } };
const active = session.active;
if (!active) return { outcome: { outcome: 'cancelled' as const } };
const outcome = await active.context.requestPermission({
@@ -583,6 +844,14 @@ export class AcpExecutor implements PluginExecutorProvider {
}
#acceptUpdate(session: RetainedSession, update: SessionUpdate): void {
+ if (session.restoring) {
+ if (session.replay) {
+ session.replay.updates++;
+ if (update.sessionUpdate === 'user_message_chunk') session.replay.userChunks++;
+ }
+ return;
+ }
+ if (session.historyGap || session.lost) return;
if (update.sessionUpdate === 'config_option_update') {
// During our mutation, its response (or rollback response) is authoritative.
if (!session.configuring && !session.lost) session.configOptions = update.configOptions;
@@ -603,6 +872,16 @@ export class AcpExecutor implements PluginExecutorProvider {
if (update.sessionUpdate === 'tool_call_update') this.#acceptTool(active, update);
}
+ #assertWorkspaceAccess(session: RetainedSession, sessionId: string): void {
+ this.#assertSession(session, sessionId);
+ if (session.restoring || session.historyGap || session.lost) {
+ throw new AcpRuntimeError(
+ 'ACP filesystem access is unavailable while Session history is untrusted',
+ 'acp_history_gap',
+ );
+ }
+ }
+
#acceptTool(active: ActivePrompt, update: ToolCall | ToolCallUpdate): void {
const snapshot = active.tools.get(update.toolCallId) ?? {
id: update.toolCallId,
@@ -715,6 +994,14 @@ export class AcpExecutor implements PluginExecutorProvider {
}
}
+function currentAcpModel(options: readonly SessionConfigOption[]): string | undefined {
+ const option = options.find(
+ (candidate) =>
+ candidate.type === 'select' && (candidate.id === 'model' || candidate.category === 'model'),
+ );
+ return option?.type === 'select' ? option.currentValue : undefined;
+}
+
function toolTextContent(content: readonly ToolCallContent[]): string {
return content
.flatMap((item) =>
@@ -760,16 +1047,94 @@ function pluginStateStore(
`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;
+ return (await storage.get(key(conversationKey))).value !== undefined;
},
async mark(conversationKey, cwd) {
await storage.set(key(conversationKey), { version: 1, cwd });
},
+ async read(conversationKey) {
+ return (await storage.get(key(conversationKey))).value;
+ },
+ async write(conversationKey, state) {
+ await storage.set(key(conversationKey), state);
+ },
};
}
+type StoredContinuity = AcpContinuityRecord | 'legacy' | 'invalid' | undefined;
+
+function decodeContinuity(value: unknown): StoredContinuity {
+ if (value === undefined) return undefined;
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return 'invalid';
+ const record = value as Record;
+ if (record.version === 1) return 'legacy';
+ if (
+ record.version !== 2 ||
+ typeof record.cwd !== 'string' ||
+ !isAbsolute(record.cwd) ||
+ typeof record.binding !== 'string' ||
+ !/^sha256:[a-f0-9]{64}$/u.test(record.binding) ||
+ !Number.isSafeInteger(record.committedPrompts) ||
+ (record.committedPrompts as number) < 0 ||
+ !['reserved', 'established', 'prompt_pending', 'committed', 'history_gap'].includes(
+ String(record.phase),
+ ) ||
+ (record.sessionId !== undefined &&
+ (typeof record.sessionId !== 'string' ||
+ !record.sessionId ||
+ record.sessionId.length > 4096)) ||
+ (record.pendingTurnId !== undefined &&
+ (typeof record.pendingTurnId !== 'string' || !record.pendingTurnId)) ||
+ (record.confirmedModel !== undefined &&
+ (typeof record.confirmedModel !== 'string' || !record.confirmedModel)) ||
+ (record.phase === 'reserved' && record.sessionId !== undefined) ||
+ (record.phase !== 'reserved' && !record.sessionId) ||
+ (record.phase === 'prompt_pending' && !record.pendingTurnId) ||
+ (record.phase !== 'prompt_pending' && record.pendingTurnId !== undefined) ||
+ (record.gapEvidence !== undefined &&
+ (record.phase !== 'history_gap' ||
+ !record.gapEvidence ||
+ typeof record.gapEvidence !== 'object' ||
+ !Number.isSafeInteger((record.gapEvidence as Record).replayedUpdates) ||
+ !Number.isSafeInteger((record.gapEvidence as Record).replayedUserChunks) ||
+ ((record.gapEvidence as Record).replayedUpdates as number) < 0 ||
+ ((record.gapEvidence as Record).replayedUserChunks as number) < 0))
+ )
+ return 'invalid';
+ return record as unknown as AcpContinuityRecord;
+}
+
+async function launchBinding(
+ adapterId: string,
+ launch: AcpLaunchSpec,
+ signal: AbortSignal,
+): Promise {
+ const paths = [launch.executable, ...(launch.requiredExecutables ?? [])];
+ const files = [];
+ for (const path of paths) {
+ signal.throwIfAborted();
+ const resolved = await checkedExecutable(path);
+ const hash = createHash('sha256');
+ for await (const chunk of createReadStream(resolved)) {
+ signal.throwIfAborted();
+ hash.update(chunk);
+ }
+ files.push({ path: resolved, digest: hash.digest('hex') });
+ }
+ return `sha256:${createHash('sha256')
+ .update(
+ JSON.stringify({
+ adapterId,
+ files,
+ args: launch.args ?? [],
+ cwd: launch.cwd ?? dirname(files[0]!.path),
+ platform: process.platform,
+ arch: process.arch,
+ }),
+ )
+ .digest('hex')}`;
+}
+
function validateAdapter(adapter: AcpAgentAdapter): AcpAgentAdapter {
if (
!adapter ||
diff --git a/packages/core/src/__tests__/executor-catalog.test.ts b/packages/core/src/__tests__/executor-catalog.test.ts
index 8695410bd1e..374de122851 100644
--- a/packages/core/src/__tests__/executor-catalog.test.ts
+++ b/packages/core/src/__tests__/executor-catalog.test.ts
@@ -48,6 +48,16 @@ test('structured capabilities survive wire normalization with immutable exact re
assert.ok(Object.isFrozen(output.modelGroups![0]!.variants[0]));
assert.notEqual(output.modelGroups, catalog.modelGroups);
});
+for (const readiness of ['restorable', 'restoring', 'restore_failed', 'history_gap'] as const)
+ test(`restoration readiness survives executor catalog normalization: ${readiness}`, () => {
+ assert.equal(
+ normalizeCatalogEntry(
+ { ...catalog, readiness, models: [], modelGroups: undefined },
+ 'external',
+ ).readiness,
+ readiness,
+ );
+ });
for (const variants of [
[
{ modelId: 'invented', level: 'high' },
diff --git a/packages/core/src/executor-catalog.ts b/packages/core/src/executor-catalog.ts
index 538ac5053b2..5d821d7083e 100644
--- a/packages/core/src/executor-catalog.ts
+++ b/packages/core/src/executor-catalog.ts
@@ -35,7 +35,11 @@ export type ExecutorReadiness =
| 'ready'
| 'unavailable'
| 'authentication_required'
- | 'history_only';
+ | 'history_only'
+ | 'restorable'
+ | 'restoring'
+ | 'restore_failed'
+ | 'history_gap';
export interface ExecutorModelChoice {
readonly id: string;
@@ -83,9 +87,16 @@ export function normalizeCatalogEntry(
!isExecutorId(id) ||
value.id !== id ||
!isCatalogText(value.displayName) ||
- !['ready', 'unavailable', 'authentication_required', 'history_only'].includes(
- value.readiness,
- ) ||
+ ![
+ 'ready',
+ 'unavailable',
+ 'authentication_required',
+ 'history_only',
+ 'restorable',
+ 'restoring',
+ 'restore_failed',
+ 'history_gap',
+ ].includes(value.readiness) ||
!Array.isArray(value.models) ||
value.models.length > 256 ||
!Array.from(value.models).every(
diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts
index 57c809310c1..2f41d5595c1 100644
--- a/packages/runtime-host/src/protocol/index.ts
+++ b/packages/runtime-host/src/protocol/index.ts
@@ -104,7 +104,8 @@ 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 = 197 as const;
+export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 198 as const;
+// 198: Executor readiness exposes explicit restore, restore-failed and history-gap states.
// 197: Queue reorder requests carry the expected queue revision. Epoch-196
// peers reject the required field or send an unfenced reorder request.
// 196: `turn.start` carries an optional durable external-message origin; older
diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts
index 048cb0e2265..f521703effa 100644
--- a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts
+++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts
@@ -23,7 +23,100 @@ import type { HostedFormSettlement } from '@maka/core/backend-types';
import type { SessionEvent } from '@maka/core/events';
import { PluginExecutorBackend } from '../plugin-executor-backend.js';
import { Context } from '../plugin-kernel.js';
-import { PluginExecutorService } from '../plugin-executor-service.js';
+import { PluginExecutorService, type PluginExecutorResult } from '../plugin-executor-service.js';
+
+for (const result of [
+ { status: 'completed', text: 'done' },
+ { status: 'cancelled', providerStopReason: 'cancelled' },
+ { status: 'failed', message: 'Agent refused the request' },
+] satisfies PluginExecutorResult[]) {
+ for (const detached of [false, true]) {
+ test(`Plugin acknowledgement for ${result.status} requires terminal consumption (detached: ${detached})`, async () => {
+ const root = new Context();
+ const service = new PluginExecutorService(root);
+ const acknowledgements: string[] = [];
+ const abandonments: string[] = [];
+ root
+ .extend({
+ maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 },
+ })
+ .executors.register({
+ id: 'remote',
+ execute: async () => result,
+ acknowledgeExecution: async (conversationKey, turnId) => {
+ acknowledgements.push(`${conversationKey}/${turnId}`);
+ },
+ abandonExecution: async (conversationKey, turnId) => {
+ abandonments.push(`${conversationKey}/${turnId}`);
+ },
+ });
+ const backend = new PluginExecutorBackend({
+ sessionId: 'session-a',
+ cwd: '/workspace',
+ binding: service.bind('session-a', 'remote'),
+ });
+ try {
+ const iterator = backend.send({ turnId: 'turn-a', text: 'task' })[Symbol.asyncIterator]();
+ assert.equal(
+ (await iterator.next()).value?.type,
+ result.status === 'completed'
+ ? 'text_complete'
+ : result.status === 'cancelled'
+ ? 'abort'
+ : 'error',
+ );
+ assert.deepEqual(acknowledgements, []);
+ assert.equal((await iterator.next()).value?.type, 'complete');
+ assert.deepEqual(
+ acknowledgements,
+ [],
+ 'terminal delivery is not itself an acknowledgement',
+ );
+ if (detached) {
+ await iterator.return?.();
+ assert.deepEqual(
+ acknowledgements,
+ [],
+ 'an unconsumed terminal event cannot be acknowledged',
+ );
+ assert.deepEqual(abandonments, ['session-a/turn-a']);
+ } else {
+ assert.equal((await iterator.next()).done, true);
+ assert.deepEqual(acknowledgements, ['session-a/turn-a']);
+ assert.deepEqual(abandonments, []);
+ }
+ } finally {
+ await backend.dispose();
+ await root.fiber.dispose();
+ }
+ });
+ }
+}
+
+test('failed Plugin acknowledgement abandons the settled external execution', async () => {
+ const { root, binding } = fixture(async () => ({ status: 'completed', text: 'done' }));
+ const abandoned: string[] = [];
+ const backend = new PluginExecutorBackend({
+ sessionId: 'session-a',
+ cwd: '/workspace',
+ binding: {
+ ...binding,
+ acknowledgeExecution: async () => {
+ throw new Error('checkpoint unavailable');
+ },
+ abandonExecution: async (conversationKey, turnId) => {
+ abandoned.push(`${conversationKey}/${turnId}`);
+ },
+ },
+ });
+ try {
+ await collect(backend.send({ turnId: 'turn-a', text: 'task' }));
+ assert.deepEqual(abandoned, ['session-a/turn-a']);
+ } finally {
+ await backend.dispose();
+ await root.fiber.dispose();
+ }
+});
test('executor backend converts plugin output and result to ordinary Session events', async () => {
const { root, binding } = fixture(async (request, context) => {
diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts
index 1bee62870a4..aa37df2d481 100644
--- a/packages/runtime/src/__tests__/plugin-executor-service.test.ts
+++ b/packages/runtime/src/__tests__/plugin-executor-service.test.ts
@@ -155,6 +155,37 @@ test('executor bindings pin one provider generation', async () => {
await root.fiber.dispose();
});
+test('executor acknowledgement is bound to one Session and provider generation', async () => {
+ const root = new Context();
+ const service = new PluginExecutorService(root);
+ const owner = plugin(root, 'profile', 'provider', 1);
+ const acknowledged: string[] = [];
+ const abandoned: string[] = [];
+ const dispose = owner.executors.register({
+ id: 'remote',
+ execute: async () => ({ status: 'completed', text: 'ok' }),
+ acknowledgeExecution: async (conversationKey, turnId) => {
+ acknowledged.push(`${conversationKey}:${turnId}`);
+ },
+ abandonExecution: async (conversationKey, turnId) => {
+ abandoned.push(`${conversationKey}:${turnId}`);
+ },
+ });
+ const binding = service.bind('session-a', 'remote');
+ await binding.acknowledgeExecution!('session-a', 'turn-a');
+ await assert.rejects(binding.acknowledgeExecution!('session-b', 'turn-b'));
+ await binding.abandonExecution!('session-a', 'turn-b');
+ await assert.rejects(binding.abandonExecution!('session-b', 'turn-c'));
+ assert.deepEqual(acknowledged, ['session-a:turn-a']);
+ assert.deepEqual(abandoned, ['session-a:turn-b']);
+ await dispose();
+ await assert.rejects(binding.acknowledgeExecution!('session-a', 'turn-c'));
+ await assert.rejects(binding.abandonExecution!('session-a', 'turn-c'));
+ assert.deepEqual(acknowledged, ['session-a:turn-a']);
+ assert.deepEqual(abandoned, ['session-a:turn-b']);
+ await root.fiber.dispose();
+});
+
test('executor completion after caller cancellation is normalized to cancelled', async () => {
const root = new Context();
const service = new PluginExecutorService(root);
diff --git a/packages/runtime/src/plugin-executor-backend.ts b/packages/runtime/src/plugin-executor-backend.ts
index e4032175fce..81bbed40212 100644
--- a/packages/runtime/src/plugin-executor-backend.ts
+++ b/packages/runtime/src/plugin-executor-backend.ts
@@ -91,18 +91,39 @@ export class PluginExecutorBackend implements AgentBackend {
const producer = this.#produce(input, messageId, abort.signal, queue).finally(() =>
queue.close(),
);
- const active: ActiveExecution = { abort, settled: producer };
+ const active: ActiveExecution = { abort, settled: producer.then(() => undefined) };
this.#active.add(active);
+ let acknowledged = false;
try {
for await (const event of queue) {
yield event;
queue.ackConsumed();
}
- await producer;
+ const returnedResult = await producer;
+ if (returnedResult) {
+ // The Runtime Kernel requests the next item only after onSessionEvent
+ // resolves. Reaching this point means its terminal event was accepted.
+ // The Plugin decides whether its external execution actually settled;
+ // uncertain execution or a failed checkpoint keeps the pending marker.
+ if (this.#binding.acknowledgeExecution)
+ try {
+ await this.#binding.acknowledgeExecution(this.sessionId, input.turnId);
+ acknowledged = true;
+ } catch {
+ /* The provider abandonment below makes the uncertainty explicit. */
+ }
+ }
} finally {
queue.noteConsumerDetached();
abort.abort(new Error('Plugin executor event consumer detached'));
- await producer.catch(() => undefined);
+ const returnedResult = await producer.catch(() => false);
+ if (
+ returnedResult &&
+ this.#binding.acknowledgeExecution &&
+ !acknowledged &&
+ this.#binding.abandonExecution
+ )
+ await this.#binding.abandonExecution(this.sessionId, input.turnId).catch(() => undefined);
this.#active.delete(active);
}
}
@@ -132,7 +153,7 @@ export class PluginExecutorBackend implements AgentBackend {
messageId: string,
signal: AbortSignal,
queue: AsyncEventQueue,
- ): Promise {
+ ): Promise {
const turnId = input.turnId;
let thinkingText = '';
const toolUseIds = new Map();
@@ -191,7 +212,7 @@ export class PluginExecutorBackend implements AgentBackend {
false,
queue,
);
- return;
+ return false;
}
if (result === undefined) {
this.#publishFailure(
@@ -201,9 +222,10 @@ export class PluginExecutorBackend implements AgentBackend {
false,
queue,
);
- return;
+ return false;
}
this.#publishResult(turnId, messageId, result, queue);
+ return true;
}
async #requestPermission(
diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts
index 3a54668d22d..a8636d3658b 100644
--- a/packages/runtime/src/plugin-executor-service.ts
+++ b/packages/runtime/src/plugin-executor-service.ts
@@ -175,6 +175,10 @@ export interface PluginExecutorProvider {
signal: AbortSignal,
): Promise;
inspectConversation?(input: PluginExecutorConversationInput): Promise;
+ /** Confirm terminal consumption for any result; the provider decides whether it can checkpoint. */
+ acknowledgeExecution?(conversationKey: string, turnId: string): Promise;
+ /** Mark a settled result uncertain when its terminal event was not durably consumed. */
+ abandonExecution?(conversationKey: string, turnId: string): Promise;
execute(
request: Readonly,
context: PluginExecutorContext,
@@ -203,6 +207,8 @@ export interface PluginExecutorBinding {
request: PluginExecutorRequest,
options?: PluginExecutorExecutionOptions,
): Promise;
+ acknowledgeExecution?(conversationKey: string, turnId: string): Promise;
+ abandonExecution?(conversationKey: string, turnId: string): Promise;
}
export interface PluginExecutorServiceOptions {
@@ -273,6 +279,12 @@ export class PluginExecutorService extends Service {
...(provider.inspectConversation
? { inspectConversation: provider.inspectConversation.bind(provider) }
: {}),
+ ...(provider.acknowledgeExecution
+ ? { acknowledgeExecution: provider.acknowledgeExecution.bind(provider) }
+ : {}),
+ ...(provider.abandonExecution
+ ? { abandonExecution: provider.abandonExecution.bind(provider) }
+ : {}),
});
const entry: RegisteredExecutor = {
...identity,
@@ -443,6 +455,29 @@ export class PluginExecutorService extends Service {
}
return this.executeEntry(entry, request, options);
},
+ ...(entry.provider.acknowledgeExecution
+ ? {
+ acknowledgeExecution: (conversationKey: string, turnId: string) =>
+ conversationKey !== sessionId
+ ? Promise.reject(new Error('Executor binding cannot cross Session scope'))
+ : this.withActiveOperation(entry, { conversationKey }, async (signal) => {
+ signal.throwIfAborted();
+ if (entry.retired) throw new ExecutorRetiredAbort(entry.provider.id);
+ await entry.provider.acknowledgeExecution!(conversationKey, turnId);
+ }),
+ }
+ : {}),
+ ...(entry.provider.abandonExecution
+ ? {
+ abandonExecution: (conversationKey: string, turnId: string) =>
+ conversationKey !== sessionId
+ ? Promise.reject(new Error('Executor binding cannot cross Session scope'))
+ : this.withActiveOperation(entry, { conversationKey }, async () => {
+ if (entry.retired) throw new ExecutorRetiredAbort(entry.provider.id);
+ await entry.provider.abandonExecution!(conversationKey, turnId);
+ }),
+ }
+ : {}),
});
}
@@ -564,6 +599,15 @@ function validateProvider(provider: PluginExecutorProvider): void {
if (typeof provider.execute !== 'function') {
throw new TypeError(`Executor implementation is invalid: ${provider.id}`);
}
+ if (
+ provider.acknowledgeExecution !== undefined &&
+ typeof provider.acknowledgeExecution !== 'function'
+ ) {
+ throw new TypeError(`Executor acknowledgement is invalid: ${provider.id}`);
+ }
+ if (provider.abandonExecution !== undefined && typeof provider.abandonExecution !== 'function') {
+ throw new TypeError(`Executor abandonment is invalid: ${provider.id}`);
+ }
if (
provider.displayName !== undefined &&
(typeof provider.displayName !== 'string' || !provider.displayName.trim())
diff --git a/packages/ui/src/__tests__/executor-model-picker.test.tsx b/packages/ui/src/__tests__/executor-model-picker.test.tsx
index 1ee7fe7fad0..c143f8cd23f 100644
--- a/packages/ui/src/__tests__/executor-model-picker.test.tsx
+++ b/packages/ui/src/__tests__/executor-model-picker.test.tsx
@@ -325,14 +325,17 @@ test('executor choice shares the native searchable list and exposes every extern
}
});
-test('history-only state keeps the executor fixed and offers a new task', async () => {
+for (const [readiness, notice] of [
+ ['history_only', '歷史仍可閱讀'],
+ ['history_gap', '進度可能超出已儲存歷史'],
+] as const) test(`${readiness} keeps the executor fixed and offers a new task`, async () => {
const dom = installTranscriptDom();
let newTasks = 0;
try {
await dom.render(
assert.fail('History cannot change executor')}
@@ -344,7 +347,7 @@ test('history-only state keeps the executor fixed and offers a new task', async
/>
,
);
- assert.ok(dom.document.body.textContent?.includes('歷史仍可閱讀'));
+ assert.ok(dom.document.body.textContent?.includes(notice));
const executorTrigger = dom.document.querySelector('.maka-executor-selector');
assert.notEqual(executorTrigger?.getAttribute('aria-disabled'), 'true');
const button = [...dom.document.querySelectorAll('button')].find(
@@ -358,6 +361,56 @@ test('history-only state keeps the executor fixed and offers a new task', async
}
});
+test('restorable task offers an explicit restore action without selecting a model', async () => {
+ const dom = installTranscriptDom();
+ let restores = 0;
+ try {
+ await dom.render(
+
+ assert.fail('Restoration must not commit a model choice')}
+ onRestore={async () => { restores++; }}
+ onSetup={() => {}}
+ onRetry={() => {}}
+ onNewTask={() => assert.fail('The Session is restorable')}
+ />
+ ,
+ );
+ assert.match(dom.document.body.textContent ?? '', /Restore it before continuing/u);
+ const button = [...dom.document.querySelectorAll('button')].find(
+ candidate => candidate.textContent === 'Restore Session',
+ );
+ assert.ok(button);
+ await act(async () => button.dispatchEvent(new dom.window.Event('click', { bubbles: true })));
+ assert.equal(restores, 1);
+ } finally { await dom.cleanup(); }
+});
+
+test('restoring task shows progress without offering another action', async () => {
+ const dom = installTranscriptDom();
+ try {
+ await dom.render(
+
+ assert.fail('Restoration is pending')}
+ onSetup={() => assert.fail('Setup is unavailable while restoring')}
+ onRetry={() => assert.fail('Retry is unavailable while restoring')}
+ onNewTask={() => assert.fail('New Task is unavailable while restoring')}
+ />
+ ,
+ );
+ assert.match(dom.document.body.textContent ?? '', /Restoring the external Session/u);
+ assert.equal([...dom.document.querySelectorAll('button')].some(button =>
+ ['Manage agents', 'Restore Session', 'New Task'].includes(button.textContent ?? '')), false);
+ } finally { await dom.cleanup(); }
+});
+
test('unavailable executors can be inspected but never committed', async () => {
const dom = installTranscriptDom();
const selections: unknown[] = [];
diff --git a/packages/ui/src/executor-model-picker.tsx b/packages/ui/src/executor-model-picker.tsx
index 66592815369..7b66869718e 100644
--- a/packages/ui/src/executor-model-picker.tsx
+++ b/packages/ui/src/executor-model-picker.tsx
@@ -53,6 +53,7 @@ export interface ExecutorModelPickerProps {
onSetup(): void;
onRetry(): void;
onNewTask(): void;
+ onRestore?(): void | Promise;
}
interface ExecutorCopy {
@@ -65,6 +66,11 @@ interface ExecutorCopy {
unavailable: string;
authentication_required: string;
history_only: string;
+ restorable: string;
+ restoring: string;
+ restore_failed: string;
+ history_gap: string;
+ restore: string;
fixed: string;
attachments: string;
retry: string;
@@ -84,6 +90,11 @@ const EXECUTOR_COPY = {
authentication_required: 'Sign in from External Agents settings.',
history_only:
'The external process was lost. History is readable; start a new task to continue.',
+ restorable: 'The external Session can be restored. Restore it before continuing.',
+ restoring: 'Restoring the external Session…',
+ restore_failed: 'Restoration failed. History is readable; retry or start a new task.',
+ history_gap: 'The Agent may be ahead of saved history. Start a new task to avoid an incomplete transcript.',
+ restore: 'Restore Session',
fixed: 'Start a new task to switch executors.',
attachments:
'This executor does not support these attachments. Remove them or select Maka. Your draft is preserved.',
@@ -101,6 +112,11 @@ const EXECUTOR_COPY = {
unavailable: '当前不可用,请检查设置后重试。',
authentication_required: '需要登录,请前往外部 Agent 设置。',
history_only: '外部进程已丢失。历史仍可阅读,请新建任务继续。',
+ restorable: '外部会话可以恢复。请先恢复,再继续对话。',
+ restoring: '正在恢复外部会话…',
+ restore_failed: '恢复失败,历史仍可阅读。请重试或新建任务。',
+ history_gap: 'Agent 的进度可能超出已保存历史。请新建任务,避免记录不完整。',
+ restore: '恢复会话',
fixed: '切换执行者需要新建任务。',
attachments: '此执行者不支持这些附件。请移除附件或选择 Maka,草稿会保留。',
retry: '重试',
@@ -117,6 +133,11 @@ const EXECUTOR_COPY = {
unavailable: '目前無法使用,請檢查設定後重試。',
authentication_required: '需要登入,請前往外部 Agent 設定。',
history_only: '外部程序已遺失。歷史仍可閱讀,請建立新任務繼續。',
+ restorable: '外部會話可以恢復。請先恢復,再繼續對話。',
+ restoring: '正在恢復外部會話…',
+ restore_failed: '恢復失敗,歷史仍可閱讀。請重試或建立新任務。',
+ history_gap: 'Agent 的進度可能超出已儲存歷史。請建立新任務,避免記錄不完整。',
+ restore: '恢復會話',
fixed: '切換執行者需要建立新任務。',
attachments: '此執行者不支援這些附件。請移除附件或選擇 Maka,草稿會保留。',
retry: '重試',
@@ -189,6 +210,12 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) {
setOpen(false);
props.onNewTask();
};
+ const restore = () => {
+ if (props.onRestore && !selecting) {
+ setSelecting(true);
+ void Promise.resolve(props.onRestore()).catch(() => setSelectionFailed(true)).finally(() => setSelecting(false));
+ }
+ };
const lockedTo = props.fixed ? props.selection?.executorId ?? NATIVE : undefined;
return (
<>
@@ -284,9 +311,11 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) {
) : (
{browsed ? copy[browsed.readiness] : copy.unavailable}
- {browsed?.readiness === 'history_only' ? (
+ {browsed?.readiness === 'restorable' || browsed?.readiness === 'restore_failed' ? (
+
+ ) : browsed?.readiness === 'history_only' || browsed?.readiness === 'history_gap' ? (
- ) : (
+ ) : browsed?.readiness === 'restoring' ? null : (
)}
@@ -310,9 +339,11 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) {
{(selectedUnavailable || props.error) && (
{selected && selected.readiness !== 'ready' ? copy[selected.readiness] : copy.unavailable}
- {selected?.readiness === 'history_only' ? (
+ {selected?.readiness === 'restorable' || selected?.readiness === 'restore_failed' ? (
+
+ ) : selected?.readiness === 'history_only' || selected?.readiness === 'history_gap' ? (
- ) : (
+ ) : selected?.readiness === 'restoring' ? null : (
<>