From 33ee1d6188e4118b42924d4a5c3a91b6cfbaafbc Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 10:24:19 +0800 Subject: [PATCH 1/7] docs(acp): record PR3 restoration prerequisite probe Generated-by: OpenAI Codex --- .../archive/antigravity-acp-pr2-acceptance.md | 6 +- .../archive/antigravity-acp-pr3-acceptance.md | 63 +++++++++++++++++++ 2 files changed, 66 insertions(+), 3 deletions(-) create mode 100644 docs/archive/antigravity-acp-pr3-acceptance.md 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..9472eaaff36 --- /dev/null +++ b/docs/archive/antigravity-acp-pr3-acceptance.md @@ -0,0 +1,63 @@ + + +# 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 probe **does 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. +Production restoration remains disabled until these behaviors are verified +with an eligible authenticated account and network route. From c2e5e1a10951f6ac951c9d5c94a8a23bd85010cc Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:00:23 +0800 Subject: [PATCH 2/7] feat(acp): restore durable external sessions safely Persist external Session identity and prompt checkpoints in Plugin storage. Resume committed turns through ACP, hold uncertain load replay outside canonical history, and expose explicit restore and history-gap states in Desktop. Refs #5103 Generated-by: OpenAI Codex --- .../main/__tests__/executor-selection.test.ts | 48 +++ .../controller/use-executor-selection.ts | 17 + .../conversation/model/executor-composer.ts | 1 + docs/antigravity-acp-plugin-rebuild.md | 26 +- .../archive/antigravity-acp-pr3-acceptance.md | 91 ++++- .../src/__tests__/acp-executor-plugin.test.ts | 322 ++++++++++++++- .../src/__tests__/acp-process.test.ts | 139 ++++++- packages/acp-executor-plugin/src/index.ts | 385 ++++++++++++++++-- .../src/__tests__/executor-catalog.test.ts | 10 + packages/core/src/executor-catalog.ts | 19 +- packages/runtime-host/src/protocol/index.ts | 3 +- .../__tests__/plugin-executor-backend.test.ts | 34 ++ .../__tests__/plugin-executor-service.test.ts | 22 + .../runtime/src/plugin-executor-backend.ts | 20 +- .../runtime/src/plugin-executor-service.ts | 24 ++ .../__tests__/executor-model-picker.test.tsx | 59 ++- packages/ui/src/executor-model-picker.tsx | 39 +- 17 files changed, 1193 insertions(+), 66 deletions(-) diff --git a/apps/desktop/src/main/__tests__/executor-selection.test.ts b/apps/desktop/src/main/__tests__/executor-selection.test.ts index 17efe588738..291d9e4486a 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 }; 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..9bed770ad51 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,28 @@ 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'); + 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-pr3-acceptance.md b/docs/archive/antigravity-acp-pr3-acceptance.md index 9472eaaff36..20e28fb1471 100644 --- a/docs/archive/antigravity-acp-pr3-acceptance.md +++ b/docs/archive/antigravity-acp-pr3-acceptance.md @@ -53,11 +53,96 @@ execution and earlier cross-process feasibility checks in Session creation but produced the same model 403. - All probe-owned Agent process groups and temporary profiles were removed. -This probe **does not pass the PR 3 prerequisite gate**. It confirms +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. -Production restoration remains disabled until these behaviors are verified -with an eligible authenticated account and network route. +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..b5ae87fa00a 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,264 @@ 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('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('runtime retains one ACP process and Session across prompts', async () => { const fixture = await executableFixture(); const protocol = fakeProtocol(); @@ -532,26 +791,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 +845,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 +965,10 @@ function fakeProtocol(): { configurationFailure?: 'response_lost' | 'unconfirmed' | 'timeout' | 'once'; sessionCreationFailure?: 'once'; promptModels: string[]; + supportsRestore: boolean; + restoreFailure?: boolean; + resumes: number; + loads: number; notifyConfiguration(model: string): void; } { const fixture = { @@ -722,6 +985,10 @@ function fakeProtocol(): { | undefined, sessionCreationFailure: undefined as 'once' | undefined, promptModels: [] as string[], + supportsRestore: false, + restoreFailure: false, + resumes: 0, + loads: 0, notifyConfiguration: (_model: string): void => {}, factory: undefined as unknown as AcpConnectionFactory, }; @@ -769,7 +1036,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 +1235,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 +1274,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..c51c1d19f95 100644 --- a/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts +++ b/packages/acp-executor-plugin/src/__tests__/acp-process.test.ts @@ -36,20 +36,38 @@ 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==='ignore-cancel'){text('waiting');return;} if(value==='wait'){promptId=m.id;text('waiting');return;} @@ -123,6 +141,7 @@ async function fixture() { }); return { root, + script, executor, request, context, @@ -167,6 +186,118 @@ 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(); + } +}); + +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..06bf291a697 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,59 @@ 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; + } + throw error; } finally { session.configuring = false; } @@ -255,12 +312,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 +356,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,6 +373,7 @@ 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) }], @@ -329,15 +392,19 @@ export class AcpExecutor implements PluginExecutorProvider { 'acp_prompt_incomplete', ); } + if (session.record) session.awaitingAck = request.turnId; 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 +440,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) + this.#sessions.delete(request.conversationKey); } const created: RetainedSession = { conversationKey: request.conversationKey, @@ -405,21 +481,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 +558,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 +634,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 +656,44 @@ 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; + } + async #applyInitialConfig( session: RetainedSession, values: Readonly>, @@ -501,7 +718,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 +736,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 +757,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,6 +770,13 @@ 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 }) => { @@ -571,6 +800,7 @@ export class AcpExecutor implements PluginExecutorProvider { async #requestPermission(session: RetainedSession, request: RequestPermissionRequest) { this.#assertSession(session, request.sessionId); + if (session.restoring) 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 +813,13 @@ 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 (update.sessionUpdate === 'config_option_update') { // During our mutation, its response (or rollback response) is authoritative. if (!session.configuring && !session.lost) session.configOptions = update.configOptions; @@ -715,6 +952,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 +1005,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 2a4cd0b470f..e888ef72215 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -103,7 +103,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 = 183 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 184 as const; +// 184: Executor readiness exposes explicit restore, restore-failed and history-gap states. // 183: Jev policy snapshots, set_jev mutation and credential locator require matching peers. // 182: Executor catalogs expose structured model families and thinking variant IDs. // 181: Canonical executor models and retained provider stop reasons after cancellation. diff --git a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts index 048cb0e2265..e45cc86f5c3 100644 --- a/packages/runtime/src/__tests__/plugin-executor-backend.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-backend.test.ts @@ -25,6 +25,40 @@ import { PluginExecutorBackend } from '../plugin-executor-backend.js'; import { Context } from '../plugin-kernel.js'; import { PluginExecutorService } from '../plugin-executor-service.js'; +test('Plugin acknowledgement follows consumption of the completed terminal event', async () => { + const root = new Context(); + const service = new PluginExecutorService(root); + const acknowledgements: string[] = []; + root + .extend({ + maka: { rootId: 'profile', packageId: 'fixture', entryId: 'provider', generation: 1 }, + }) + .executors.register({ + id: 'remote', + execute: async () => ({ status: 'completed', text: 'done' }), + acknowledgeExecution: async (conversationKey, turnId) => { + acknowledgements.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, 'text_complete'); + assert.deepEqual(acknowledgements, []); + assert.equal((await iterator.next()).value?.type, 'complete'); + assert.deepEqual(acknowledgements, [], 'terminal delivery is not itself an acknowledgement'); + assert.equal((await iterator.next()).done, true); + assert.deepEqual(acknowledgements, ['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) => { assert.equal(request.instructions, 'child instructions'); diff --git a/packages/runtime/src/__tests__/plugin-executor-service.test.ts b/packages/runtime/src/__tests__/plugin-executor-service.test.ts index 1bee62870a4..3a6437b3c3d 100644 --- a/packages/runtime/src/__tests__/plugin-executor-service.test.ts +++ b/packages/runtime/src/__tests__/plugin-executor-service.test.ts @@ -155,6 +155,28 @@ 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 dispose = owner.executors.register({ + id: 'remote', + execute: async () => ({ status: 'completed', text: 'ok' }), + acknowledgeExecution: async (conversationKey, turnId) => { + acknowledged.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')); + assert.deepEqual(acknowledged, ['session-a:turn-a']); + await dispose(); + await assert.rejects(binding.acknowledgeExecution!('session-a', 'turn-c')); + assert.deepEqual(acknowledged, ['session-a:turn-a']); + 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..9ba07e65ff2 100644 --- a/packages/runtime/src/plugin-executor-backend.ts +++ b/packages/runtime/src/plugin-executor-backend.ts @@ -91,14 +91,23 @@ 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); try { for await (const event of queue) { yield event; queue.ackConsumed(); } - await producer; + const completed = await producer; + if (completed) { + // The Runtime Kernel requests the next item only after onSessionEvent + // resolves. Reaching this point means its terminal event was accepted. + // A failed Plugin checkpoint leaves the conservative pending marker. + if (this.#binding.acknowledgeExecution) + await this.#binding + .acknowledgeExecution(this.sessionId, input.turnId) + .catch(() => undefined); + } } finally { queue.noteConsumerDetached(); abort.abort(new Error('Plugin executor event consumer detached')); @@ -132,7 +141,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 +200,7 @@ export class PluginExecutorBackend implements AgentBackend { false, queue, ); - return; + return false; } if (result === undefined) { this.#publishFailure( @@ -201,9 +210,10 @@ export class PluginExecutorBackend implements AgentBackend { false, queue, ); - return; + return false; } this.#publishResult(turnId, messageId, result, queue); + return result.status === 'completed'; } async #requestPermission( diff --git a/packages/runtime/src/plugin-executor-service.ts b/packages/runtime/src/plugin-executor-service.ts index 3a54668d22d..c76edf15229 100644 --- a/packages/runtime/src/plugin-executor-service.ts +++ b/packages/runtime/src/plugin-executor-service.ts @@ -175,6 +175,8 @@ export interface PluginExecutorProvider { signal: AbortSignal, ): Promise; inspectConversation?(input: PluginExecutorConversationInput): Promise; + /** Confirm a completed Run only after its terminal event is durably consumed. */ + acknowledgeExecution?(conversationKey: string, turnId: string): Promise; execute( request: Readonly, context: PluginExecutorContext, @@ -203,6 +205,7 @@ export interface PluginExecutorBinding { request: PluginExecutorRequest, options?: PluginExecutorExecutionOptions, ): Promise; + acknowledgeExecution?(conversationKey: string, turnId: string): Promise; } export interface PluginExecutorServiceOptions { @@ -273,6 +276,9 @@ export class PluginExecutorService extends Service { ...(provider.inspectConversation ? { inspectConversation: provider.inspectConversation.bind(provider) } : {}), + ...(provider.acknowledgeExecution + ? { acknowledgeExecution: provider.acknowledgeExecution.bind(provider) } + : {}), }); const entry: RegisteredExecutor = { ...identity, @@ -443,6 +449,18 @@ 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); + }), + } + : {}), }); } @@ -564,6 +582,12 @@ 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.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 64616129357..674fea5d877 100644 --- a/packages/ui/src/executor-model-picker.tsx +++ b/packages/ui/src/executor-model-picker.tsx @@ -52,6 +52,7 @@ export interface ExecutorModelPickerProps { onSetup(): void; onRetry(): void; onNewTask(): void; + onRestore?(): void | Promise; } interface ExecutorCopy { @@ -64,6 +65,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; @@ -83,6 +89,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.', @@ -100,6 +111,11 @@ const EXECUTOR_COPY = { unavailable: '当前不可用,请检查设置后重试。', authentication_required: '需要登录,请前往外部 Agent 设置。', history_only: '外部进程已丢失。历史仍可阅读,请新建任务继续。', + restorable: '外部会话可以恢复。请先恢复,再继续对话。', + restoring: '正在恢复外部会话…', + restore_failed: '恢复失败,历史仍可阅读。请重试或新建任务。', + history_gap: 'Agent 的进度可能超出已保存历史。请新建任务,避免记录不完整。', + restore: '恢复会话', fixed: '切换执行者需要新建任务。', attachments: '此执行者不支持这些附件。请移除附件或选择 Maka,草稿会保留。', retry: '重试', @@ -116,6 +132,11 @@ const EXECUTOR_COPY = { unavailable: '目前無法使用,請檢查設定後重試。', authentication_required: '需要登入,請前往外部 Agent 設定。', history_only: '外部程序已遺失。歷史仍可閱讀,請建立新任務繼續。', + restorable: '外部會話可以恢復。請先恢復,再繼續對話。', + restoring: '正在恢復外部會話…', + restore_failed: '恢復失敗,歷史仍可閱讀。請重試或建立新任務。', + history_gap: 'Agent 的進度可能超出已儲存歷史。請建立新任務,避免記錄不完整。', + restore: '恢復會話', fixed: '切換執行者需要建立新任務。', attachments: '此執行者不支援這些附件。請移除附件或選擇 Maka,草稿會保留。', retry: '重試', @@ -188,6 +209,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 ( <> @@ -283,9 +310,11 @@ export function ExecutorModelPicker(props: ExecutorModelPickerProps) { ) : (

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

- {browsed?.readiness === 'history_only' ? ( + {browsed?.readiness === 'restorable' || browsed?.readiness === 'restore_failed' ? ( +
@@ -307,9 +336,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' ? ( +