diff --git a/apps/desktop/e2e-budget.json b/apps/desktop/e2e-budget.json index fd50372e03..9e23334fa3 100644 --- a/apps/desktop/e2e-budget.json +++ b/apps/desktop/e2e-budget.json @@ -21,6 +21,10 @@ "tests": 1, "electron": "bounded paging crosses renderer, preload and Host transcript storage; the return-to-tail assertion also uses a real pointer hit-test" }, + "plan-execution-lifecycle.spec.ts": { + "tests": 1, + "electron": "a Plan execution's progress is written by a live backend Turn and Stop settles it through the real Host turn lifecycle; the interrupted execution must then be read back by a fresh renderer over preload/IPC after a complete Electron restart, with its per-step statuses intact, before the resumed Turn can finish it" + }, "proxy-password-editing.spec.ts": { "tests": 1, "electron": "the password never reaches the renderer; only the Host can report passwordConfigured and authenticate offline" diff --git a/apps/desktop/e2e/plan-execution-lifecycle.spec.ts b/apps/desktop/e2e/plan-execution-lifecycle.spec.ts new file mode 100644 index 0000000000..700232eb3d --- /dev/null +++ b/apps/desktop/e2e/plan-execution-lifecycle.spec.ts @@ -0,0 +1,144 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { COMPOSER_INPUT, awaitSendReady, ensureSidebarExpanded, expect, test } from './fixtures'; + +/** + * Plan mode's deterministic proposal prompt. Mirrors + * `DESKTOP_E2E_PLAN_PROPOSAL_PROMPT` in + * `packages/runtime-host/src/test-only/desktop-e2e-execution.ts`. The literal is + * restated rather than imported because that test-only module has no package + * subpath (the release manifest drops the test-only entry outright), so a + * divergence fails this spec at its first assertion instead of passing quietly. + */ +const PLAN_PROPOSAL_PROMPT = '__e2e_plan_proposal__'; + +/** + * Electron-owned mechanism: a Plan execution's progress is written to the Host's + * Plan store *by the backend Turn* (the runtime binds `update_plan` per active + * execution) and is read back by a *fresh renderer* through preload/IPC after the + * whole Electron application is restarted — a new main process, a new Host + * composition, a new renderer, and a re-opened SQLite Plan store. Every + * assertion after `restart()` is about that cross-process durable read. + * + * What a lower-tier test would miss: `src/main/__tests__/plan-mode-panel-*.test.ts` + * drive the panel against a faked `window.maka` and a fake DOM, so they cannot + * show that an interrupted execution survives an application restart, that the + * restarted renderer's `getPlanState` preload read returns the same step + * statuses, or that Stop during a live Turn settles through + * RootTurnCoordinator → SessionManager into a durable interruption. Those three + * hops only exist once a real main process, a real Host and a real preload + * bridge are all running. + */ +test('an approved Plan keeps its progress across Stop and a full Electron restart, then resumes to completion', async ({ + sessionLocalWindow, +}, testInfo) => { + let { page } = sessionLocalWindow; + const { restart } = sessionLocalWindow; + + // 1. Plan mode + proposal. The mode is entered through the composer's own + + // menu, so the Session the proposal lands in is created by the real path. + await page.locator('.maka-composer .maka-composer-plus-menu button').click(); + await page.getByRole('menuitemcheckbox', { name: 'Plan', exact: true }).click(); + await expect(page.locator('.maka-composer-mode-button[data-mode="plan"]')).toBeVisible(); + + await page.locator(COMPOSER_INPUT).fill(PLAN_PROPOSAL_PROMPT); + await awaitSendReady(page); + await page.locator(COMPOSER_INPUT).press('Enter'); + + const proposalCard = page.locator('.plan-proposal-card[data-status="pending_approval"]'); + await expect(proposalCard).toBeVisible(); + await expect(proposalCard.locator('.plan-proposal-steps > li')).toHaveCount(3); + + await ensureSidebarExpanded(page); + const sessionId = await page + .locator('[data-session-id]:has([aria-current="page"])') + .getAttribute('data-session-id'); + expect(sessionId).toBeTruthy(); + + // 2. Approve through the panel's real control. Approval starts a second Host + // Turn whose request the E2E backend answers with one real `update_plan` + // write, so the panel has to show acquired progress — not the 0/3 an + // approval that never reached a Turn would leave behind. + const executionPanel = page.locator('.plan-execution-panel'); + await page.getByRole('button', { name: '执行计划', exact: true }).click(); + await expect(executionPanel).toBeVisible(); + await expect(executionPanel.locator('.plan-execution-count')).toHaveText('1/3 步'); + await expect(executionPanel).toContainText('正在执行计划'); + + // 3. Interrupt the live Turn through the composer's own stop control. The + // backend Turn is parked, so Stop is the user's only way out of it. + const stop = page.getByRole('button', { name: '停止', exact: true }); + await expect(stop).toBeVisible(); + await stop.click(); + + // Interruption preserves the progress the Turn had already recorded; losing + // it would drop the panel back to 0/3 and make the resumed run redo step 1. + await expect(executionPanel).toContainText('计划已中断'); + await expect(executionPanel.locator('.plan-execution-count')).toHaveText('1/3 步'); + + // 4. The Electron-owned assertion: restart the application and let a fresh + // renderer read the interrupted execution back over preload/IPC. + page = await restart(); + await ensureSidebarExpanded(page); + await page.locator(`[data-session-id=${JSON.stringify(sessionId)}]`).click(); + + const recoveredPanel = page.locator('.plan-execution-panel'); + await expect(recoveredPanel).toBeVisible(); + await expect(recoveredPanel).toContainText('计划已中断'); + await expect(recoveredPanel.locator('.plan-execution-count')).toHaveText('1/3 步'); + // Expand the execution body: the count alone would also match a run whose + // per-step statuses were rebuilt, and those statuses are what resume reads. + // The resume control lives in this body, so opening it is also how the user + // reaches the action the next step takes. The disclosure's own trigger is the + // root's direct-child button: the body's resume/abandon buttons are nested + // inside the same root and stay out of the accessibility tree until it opens. + await recoveredPanel.locator('.plan-execution-toggle > button').click(); + const recoveredSteps = recoveredPanel.locator('.plan-execution-steps > li'); + await expect(recoveredSteps).toHaveCount(3); + await expect(recoveredSteps.nth(0)).toHaveAttribute('data-status', 'completed'); + await expect(recoveredSteps.nth(1)).toHaveAttribute('data-status', 'in_progress'); + await expect(recoveredSteps.nth(2)).toHaveAttribute('data-status', 'pending'); + await page.screenshot({ path: testInfo.outputPath('interrupted-after-restart.png') }); + + // 5. Resume through the panel's real control, and let the resumed Turn finish + // every remaining step. + await page.getByRole('button', { name: '恢复执行', exact: true }).click(); + + // `PlanExecutionPanel` renders `active ?? lastInterrupted`, so once the last + // step lands there is neither an active nor an interrupted execution and the + // panel — its `3/3 步` label included — unmounts. Assert the terminal count on + // the same authority the panel reads through (`getPlanState` over the preload + // bridge) rather than inventing a DOM state the product never renders. + await expect(page.locator('.plan-execution-panel')).toHaveCount(0); + await expect + .poll(() => + page.evaluate(async (id) => { + const state = await window.maka.sessions.getPlanState(id); + const execution = state.executions.at(-1); + return { + activeExecutionId: state.activeExecutionId ?? null, + status: execution?.status ?? null, + completed: execution?.steps.filter((step) => step.status === 'completed').length ?? 0, + total: execution?.steps.length ?? 0, + }; + }, sessionId!), + ) + .toEqual({ activeExecutionId: null, status: 'completed', completed: 3, total: 3 }); +}); diff --git a/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts b/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts index 92130a1adf..c7f2d5f36e 100644 --- a/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts +++ b/packages/runtime-host/src/__tests__/desktop-e2e-execution.test.ts @@ -36,7 +36,7 @@ function backendContext(overrides: Partial = {}): Backend } test('Desktop E2E compaction requires a checkpoint recorder', async () => { - const backend = new DesktopE2eBackend(backendContext()); + const backend = new DesktopE2eBackend(backendContext(), () => undefined); await assert.rejects( backend.compactHistory({ turnId: 'turn-1', @@ -55,6 +55,9 @@ test('Desktop E2E compaction records a deterministic checkpoint', async () => { recorded.push({ checkpoint, turnId }); }, }), + // Plan driving is not part of the compaction contract; this backend never + // sends a Plan Turn, so the authority is deliberately absent. + () => undefined, ); const result = await backend.compactHistory({ diff --git a/packages/runtime-host/src/server/execution-composition.ts b/packages/runtime-host/src/server/execution-composition.ts index af992b24af..61526c2fe7 100644 --- a/packages/runtime-host/src/server/execution-composition.ts +++ b/packages/runtime-host/src/server/execution-composition.ts @@ -27,7 +27,7 @@ import type { ArtifactKind, ArtifactRecord } from '@maka/core/artifacts'; import { NO_REAL_CONNECTION_CODE } from '@maka/core/connection-error-copy'; import type { RuntimeExecutionConnection } from '@maka/core/llm-connections'; import { generalizedErrorMessage } from '@maka/core/redaction'; -import { emptyPlanSessionState } from '@maka/core/plan'; +import { emptyPlanSessionState, type PlanStore } from '@maka/core/plan'; import { readLogicalRuntimeExecutionForRun } from '@maka/core/runtime-logical-execution'; import type { PermissionMode } from '@maka/core/permission'; import { @@ -295,6 +295,17 @@ export interface CreateExecutionRuntimeHostCompositionOptions { export interface ExecutionRuntimeHostCompositionDependencies { readonly executionPersistenceProvider?: ExecutionPersistenceProvider; readonly primaryBackendFactory?: BackendFactory; + /** + * Test-only observer for the composed interactive Plan authority, in the same + * spirit as a candidate entrypoint that selects a test-only entry module: the + * Desktop E2E composition is its only consumer, and no production caller + * passes it. + * + * The Plan tools are built from exactly this instance, so a caller that holds + * it can drive the real Plan lifecycle. An unset observer adds no work and no + * error path to composition. + */ + readonly observePlanStore?: (store: PlanStore) => void; readonly workHubRoutingModel?: HostWorkHubRoutingModel; readonly oauthAuthorization?: Pick< HostOAuthCoordinatorInput, @@ -924,6 +935,7 @@ export async function createExecutionRuntimeHostComposition( (sessionId) => continuityCoordinator.enqueueSessionDomainChanged(sessionId, 'plan'), context.requestDrain, ); + dependencies.observePlanStore?.(planStore); unsubscribeTranscriptChanges = stores.sessionStore.subscribeTranscriptChanges((sessionId) => continuityCoordinator.enqueueCanonicalRefresh(sessionId), ); diff --git a/packages/runtime-host/src/test-only/desktop-e2e-execution.ts b/packages/runtime-host/src/test-only/desktop-e2e-execution.ts index 65754a5f3b..883e541433 100644 --- a/packages/runtime-host/src/test-only/desktop-e2e-execution.ts +++ b/packages/runtime-host/src/test-only/desktop-e2e-execution.ts @@ -17,22 +17,119 @@ * under the License. */ +import { randomUUID } from 'node:crypto'; import type { BackendCompactHistoryInput, BackendCompactHistoryResult, + BackendSendInput, } from '@maka/core/backend-types'; +import type { SessionEvent } from '@maka/core/events'; +import type { PlanStepStatus, PlanStore } from '@maka/core/plan'; import { buildHistoryCompactCheckpoint } from '@maka/runtime/history-compact-checkpoint'; +import { buildSubmitPlanTool, buildUpdatePlanTool } from '@maka/runtime/plan-tools'; import { FakeBackend } from '@maka/runtime/test-only/fake-backend'; import { type BackendFactoryContext } from '@maka/runtime/session-manager'; +import type { MakaToolContext } from '@maka/runtime/tool-runtime'; import type { ExecutionRuntimeHostCandidateDependencies } from '../server/execution-candidate.js'; import { createExecutionRuntimeHostComposition } from '../server/execution-composition.js'; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + /** Fresh Desktop E2E workspaces never reconnect; keep election retry, skip production grace. */ export const DESKTOP_E2E_IDLE_GRACE_MS = 500; +/** + * Plan-mode sentinel prompt. Its Turn submits a deterministic three-step + * proposal through the real `SubmitPlan` tool, so the Plan panel renders a + * proposal that came out of the durable Plan store instead of fixture JSON. + */ +export const DESKTOP_E2E_PLAN_PROPOSAL_PROMPT = '__e2e_plan_proposal__'; + +/** + * Step ids the spec names. Canonical Plan entity ids, with titles short enough + * that the panel row stays readable at every status. + */ +export const DESKTOP_E2E_PLAN_STEP_IDS = [ + 'e2e-plan-step-1', + 'e2e-plan-step-2', + 'e2e-plan-step-3', +] as const; + +const DESKTOP_E2E_PLAN_STEPS = [ + { + id: DESKTOP_E2E_PLAN_STEP_IDS[0], + title: 'Collect the failing evidence', + description: 'Record the current failure output before changing anything.', + }, + { + id: DESKTOP_E2E_PLAN_STEP_IDS[1], + title: 'Apply the narrow fix', + description: 'Change only the code the recorded evidence points at.', + }, + { + id: DESKTOP_E2E_PLAN_STEP_IDS[2], + title: 'Verify the fix end to end', + description: 'Re-run the affected checks and confirm they pass.', + }, +]; + +/** + * One stable `toolCallId` per logical Plan action. Both Plan tools declare + * `recoveryMode: 'idempotent'`, and the durable operation id also carries the + * Turn id, so a replayed action reconciles against its receipt instead of + * writing a second mutation. + */ +const DESKTOP_E2E_PLAN_TOOL_CALL_ID = 'desktop-e2e-plan'; + +/** + * The composed Host authority the real Plan tools are built from. + * + * Deliberately not `BackendFactoryContext.tools`: that field is a subagent-only + * tool ceiling (`runtime-kernel.ts` fills it inside the subagent activation + * branch), so a main-session fake backend never receives a tool list. The Host + * publishes the interactive Plan store through + * `ExecutionRuntimeHostCompositionDependencies.observePlanStore`, and the real + * Plan tools are built from exactly that instance. + */ +export type DesktopE2ePlanAuthority = () => PlanStore | undefined; + +/** A step line as the Host renders it into an execution request. */ +interface DesktopE2ePlanRequestStep { + readonly id: string; + readonly status: PlanStepStatus; +} + +/** + * `renderExecutionRequest` (plan-coordinator.ts) renders the request the model + * reads as a header plus one `- [] ` line per step. Nothing + * else the model sees carries the ids `update_plan` requires, so this shape is + * what identifies a Plan execution Turn. + */ +const DESKTOP_E2E_PLAN_REQUEST_HEADER = + /^(?:Execute|Resume) the approved plan execution [A-Za-z0-9_-]+\.$/; +const DESKTOP_E2E_PLAN_REQUEST_STEP = + /^- ([A-Za-z0-9_-]+) \[(pending|in_progress|completed|skipped)\] /; + +function parsePlanExecutionRequest(text: string): DesktopE2ePlanRequestStep[] | undefined { + const lines = text.split('\n'); + if (!DESKTOP_E2E_PLAN_REQUEST_HEADER.test(lines[0] ?? '')) return undefined; + const steps: DesktopE2ePlanRequestStep[] = []; + for (const line of lines) { + const match = DESKTOP_E2E_PLAN_REQUEST_STEP.exec(line); + if (match) steps.push({ id: match[1]!, status: match[2] as PlanStepStatus }); + } + return steps.length > 0 ? steps : undefined; +} + /** The desktop E2E backend mirrors the one control capability exercised by the slash menu. */ export class DesktopE2eBackend extends FakeBackend { - constructor(private readonly backendContext: BackendFactoryContext) { + /** Set by `stop()`; the Plan progress Turn is held open until the test stops it. */ + private planTurnStopped = false; + + constructor( + private readonly backendContext: BackendFactoryContext, + private readonly planAuthority: DesktopE2ePlanAuthority, + ) { super(backendContext); } @@ -63,6 +160,202 @@ export class DesktopE2eBackend extends FakeBackend { await recordCheckpoint(checkpoint, input.turnId); return { outcome: { kind: 'compacted', checkpointId: checkpoint.checkpointId } }; } + + /** + * The base `FakeBackend.stop()` flips a private flag this subclass cannot + * read, so the Plan hold observes the same public call the runtime makes when + * the user stops the Turn. + */ + override async stop(): Promise<void> { + this.planTurnStopped = true; + await super.stop(); + } + + override async *send(input: BackendSendInput): AsyncIterable<SessionEvent> { + if (input.text === DESKTOP_E2E_PLAN_PROPOSAL_PROMPT) { + yield* this.sendPlanProposalTurn(input); + return; + } + const request = parsePlanExecutionRequest(input.text); + if (request) { + // The Host binds `update_plan` only while an execution is active, and to + // exactly that execution's id (interactive-run-composer.ts). Read the id + // now rather than caching one across Turns: a resumed execution keeps its + // identity, while a new proposal produces a new execution. + const executionId = await this.activePlanExecutionId(); + if (executionId) { + yield* this.sendPlanExecutionTurn(input, request, executionId); + return; + } + } + yield* super.send(input); + } + + private async activePlanExecutionId(): Promise<string | undefined> { + return (await this.requirePlanStore().readState(this.sessionId)).activeExecutionId; + } + + private requirePlanStore(): PlanStore { + const store = this.planAuthority(); + if (!store) { + throw new Error('Desktop E2E Plan capability requires the composed Plan store observer'); + } + return store; + } + + /** + * Which Plan Turn this is, and why the tools list cannot answer it: + * + * `BackendFactoryContext.tools` is filled only for subagent activations + * (runtime-kernel.ts), so a main-session fake backend never sees a tool list. + * The equivalent binding is read from the same authority the Host composes + * from — the Host binds `update_plan` (and `cancel_plan`) only while an + * execution is active, and to that execution's id. + * + * - no active execution: not a Plan execution Turn, so the plain reply runs; + * - every rendered step `pending`: the approve Turn. A freshly approved + * execution starts with every step pending, so the fake records one real + * step of progress (first `completed`, next `in_progress`) and then holds + * the Turn open until the test stops it, which is what makes the interrupt + * leg land on durable progress rather than on an untouched execution; + * - any step already `completed`, `skipped` or `in_progress`: the resume + * Turn. Progress survived the interruption, so the fake completes every + * remaining step and lets the Turn settle into the terminal state. + */ + private async *sendPlanExecutionTurn( + input: BackendSendInput, + request: readonly DesktopE2ePlanRequestStep[], + executionId: string, + ): AsyncIterable<SessionEvent> { + this.planTurnStopped = false; + const turnId = input.turnId; + const fresh = request.every(({ status }) => status === 'pending'); + const steps = request.map((step, index) => ({ + id: step.id, + status: fresh + ? index === 0 + ? ('completed' as const) + : index === 1 + ? ('in_progress' as const) + : ('pending' as const) + : step.status === 'completed' || step.status === 'skipped' + ? step.status + : ('completed' as const), + })); + const updatePlan = buildUpdatePlanTool(this.requirePlanStore(), executionId); + yield* this.recordPlanToolCall({ + turnId, + toolName: updatePlan.name, + args: { steps }, + run: (context) => updatePlan.impl({ steps }, context), + text: fresh + ? 'Recorded the first Plan step and started the next one.' + : 'Completed the remaining Plan steps.', + }); + if (!fresh) { + yield this.turnEnd(turnId, 'end_turn'); + return; + } + // Hold the Turn open the way FAKE_HOLD_OPEN_PROMPT does, so the composer + // still offers Stop while the panel shows the intermediate count. + while (!this.planTurnStopped) await sleep(5); + yield { type: 'abort', id: randomUUID(), turnId, ts: Date.now(), reason: 'user_stop' }; + yield this.turnEnd(turnId, 'user_stop'); + } + + private async *sendPlanProposalTurn(input: BackendSendInput): AsyncIterable<SessionEvent> { + this.planTurnStopped = false; + // A real model needs a moment before its first tool call; giving the + // session subscription the same room keeps this fixture from racing setup. + await sleep(100); + const turnId = input.turnId; + const submitPlan = buildSubmitPlanTool(this.requirePlanStore()); + const args = { + title: 'Ship the deterministic Plan fixture', + overview: 'Three bounded steps that keep the panel readable while the run is in flight.', + steps: [...DESKTOP_E2E_PLAN_STEPS], + }; + yield* this.recordPlanToolCall({ + turnId, + toolName: submitPlan.name, + args, + run: (context) => submitPlan.impl(args, context), + text: 'Plan submitted for approval.', + }); + yield this.turnEnd(turnId, 'end_turn'); + } + + /** + * Runs one real Plan tool and emits the transcript events a provider-backed + * Turn would have emitted for that call, so the Turn carries its own evidence + * that the branch ran. + */ + private async *recordPlanToolCall(input: { + turnId: string; + toolName: string; + args: unknown; + run: (context: MakaToolContext) => unknown; + text: string; + }): AsyncIterable<SessionEvent> { + const toolUseId = randomUUID(); + yield { + type: 'tool_start', + id: randomUUID(), + turnId: input.turnId, + stepId: randomUUID(), + ts: Date.now(), + toolUseId, + toolName: input.toolName, + args: input.args, + }; + const result = await input.run(this.planToolContext(input.turnId)); + yield { + type: 'tool_result', + id: randomUUID(), + turnId: input.turnId, + ts: Date.now(), + toolUseId, + isError: false, + content: { kind: 'json', value: result }, + }; + const messageId = randomUUID(); + for (const chunk of input.text.match(/[\s\S]{1,9}/g) ?? [input.text]) { + yield { + type: 'text_delta', + id: randomUUID(), + turnId: input.turnId, + ts: Date.now(), + messageId, + text: chunk, + }; + } + yield { + type: 'text_complete', + id: randomUUID(), + turnId: input.turnId, + ts: Date.now(), + messageId, + text: input.text, + }; + } + + private turnEnd(turnId: string, stopReason: 'end_turn' | 'user_stop'): SessionEvent { + return { type: 'complete', id: randomUUID(), turnId, ts: Date.now(), stopReason }; + } + + /** + * The Plan tools read only `sessionId`, `turnId` and `toolCallId` (see + * `planToolOperationId`). Everything else on `MakaToolContext` belongs to + * Runtime dispatch — abort signal, output emitters, child-spawn authority — + * which this deterministic fixture deliberately bypasses. + */ + private planToolContext(turnId: string): MakaToolContext { + return { + sessionId: this.sessionId, + turnId, + toolCallId: DESKTOP_E2E_PLAN_TOOL_CALL_ID, + } as unknown as MakaToolContext; + } } const DESKTOP_E2E_OAUTH_AUTHORIZATION = { @@ -85,19 +378,29 @@ const DESKTOP_E2E_OAUTH_AUTHORIZATION = { }; export function createDesktopE2eExecutionCandidateDependencies(): ExecutionRuntimeHostCandidateDependencies { + // One captured authority per composed Host. The backend factory runs when a + // Turn activates, always after the composition handed its Plan store over, so + // the backend reads the captured instance rather than a copy. + let planStore: PlanStore | undefined; return { - createComposition: (context, compositionOptions) => - createExecutionRuntimeHostComposition( + createComposition: async (context, compositionOptions) => { + const composition = await createExecutionRuntimeHostComposition( context, { ...compositionOptions, bootstrapRuntimePolicy: false, }, { - primaryBackendFactory: (backendContext) => new DesktopE2eBackend(backendContext), + primaryBackendFactory: (backendContext) => + new DesktopE2eBackend(backendContext, () => planStore), oauthAuthorization: DESKTOP_E2E_OAUTH_AUTHORIZATION, + observePlanStore: (store) => { + planStore = store; + }, }, - ), + ); + return composition; + }, }; }