diff --git a/apps/desktop/e2e/goal-dialog-budget.spec.ts b/apps/desktop/e2e/goal-dialog-budget.spec.ts new file mode 100644 index 0000000000..6154e2fbec --- /dev/null +++ b/apps/desktop/e2e/goal-dialog-budget.spec.ts @@ -0,0 +1,66 @@ +import { test, expect, COMPOSER_INPUT } from './fixtures'; + +/** + * Arming a Goal starts unattended token spending, and the two budgets in this + * dialog are what stops it. A budget the form shows but does not send is + * therefore the one failure this dialog must not have — most sharply when the + * value is dropped rather than altered, because an absent token budget is not + * a smaller ceiling but no ceiling at all. + * + * The assertions read the Goal back from the Host rather than watching the + * bridge call, so they answer what was actually armed. + */ +test('an unsendable budget blocks Start instead of arming a different one', async ({ + window: page, +}) => { + // The + menu only offers a Goal for a Session that exists, so seed one and + // let its Turn settle first — a live Turn disables the entry too. + const composer = page.locator(COMPOSER_INPUT); + await composer.fill('seed session'); + await composer.press('Enter'); + await expect(page.getByRole('log').getByText(/Fake backend received: seed session/)).toBeVisible(); + await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 }); + + const sessionId = await page.evaluate(async () => (await window.maka.sessions.list())[0]?.id); + expect(sessionId).toBeTruthy(); + const armedGoal = () => + page.evaluate(async (id: string) => await window.maka.goal.get(id), sessionId as string); + + await page.getByRole('button', { name: '添加上下文' }).click(); + await page.getByRole('menuitem', { name: '设定 Goal…' }).click(); + + const dialog = page.getByRole('dialog'); + await dialog.getByLabel(/达成条件/).fill('所有测试通过'); + const start = dialog.getByRole('button', { name: '开始' }); + await expect(start).toBeEnabled(); + + // Below the Host's own minimum. The field this replaced kept such text to + // itself and left the sent budget null, so Start stayed enabled and armed no + // ceiling at all. + await dialog.getByLabel(/Token 预算/).fill('500'); + await expect(start).toBeDisabled(); + await expect(dialog.getByText(/请填不小于 1000 的整数/)).toBeVisible(); + + await dialog.getByLabel(/Token 预算/).fill('5000'); + await expect(start).toBeEnabled(); + + // Above the Host's ceiling on turns; the same rule from the other side. + await dialog.getByLabel(/最多轮数/).fill('250'); + await expect(start).toBeDisabled(); + await expect(await armedGoal()).toBeNull(); + + await dialog.getByLabel(/最多轮数/).fill('25'); + await expect(start).toBeEnabled(); + await start.click(); + + await expect + .poll(async () => { + const goal = await armedGoal(); + return goal && { + condition: goal.condition, + maxIterations: goal.maxIterations, + tokenBudget: goal.tokenBudget, + }; + }) + .toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 }); +}); diff --git a/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts new file mode 100644 index 0000000000..c327a6fd93 --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-arm-outcome.test.ts @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { GoalArmOutcome } from '../../shared/goal-arm.js'; +import { interpretGoalArmOutcome } from '../../renderer/goal-arm-outcome.js'; +import { getShellCopy } from '../../renderer/locales/shell-copy.js'; + +test('successful Goal arming closes while every reconciliation result locks the form', () => { + const goal = { + id: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'All tests pass', + status: 'active' as const, + setAt: 1, + iterations: 0, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 0, + tokensBaselinePending: false, + }; + const cases: Array<{ + outcome: GoalArmOutcome; + expected: ReturnType; + }> = [ + { + outcome: { kind: 'armed', goal }, + expected: { action: 'close' }, + }, + { + outcome: { + kind: 'reconciled', + currentGoal: goal, + matchesRequestedState: true, + }, + expected: { action: 'lock', notice: { kind: 'matching_goal', goal } }, + }, + { + outcome: { + kind: 'reconciled', + currentGoal: goal, + matchesRequestedState: false, + }, + expected: { action: 'lock', notice: { kind: 'different_goal', goal } }, + }, + { + outcome: { + kind: 'reconciled', + currentGoal: null, + matchesRequestedState: false, + }, + expected: { action: 'lock', notice: { kind: 'no_goal' } }, + }, + { + outcome: { kind: 'reconciliation_unavailable' }, + expected: { action: 'lock', notice: { kind: 'unavailable' } }, + }, + ]; + + for (const { outcome, expected } of cases) { + assert.deepEqual(interpretGoalArmOutcome(outcome), expected); + } +}); + +test('Goal reconciliation copy explains authoritative state in Chinese and English', () => { + const zh = getShellCopy('zh').goalDialog; + assert.match( + zh.reconciledMatching('所有测试通过', zh.statusLabels.active), + /所有测试通过.*进行中.*无法确认.*提交/, + ); + assert.match(zh.reconciledNoGoal, /未读到 Goal/); + assert.match(zh.reconciliationUnavailable, /不会重复提交/); + + const en = getShellCopy('en').goalDialog; + assert.match( + en.reconciledDifferent('All tests pass', en.statusLabels.paused), + /All tests pass.*Paused.*differs/, + ); + assert.match(en.reconciliationUnavailable, /will not submit twice/); +}); diff --git a/apps/desktop/src/main/__tests__/goal-dialog.test.tsx b/apps/desktop/src/main/__tests__/goal-dialog.test.tsx new file mode 100644 index 0000000000..cf588d3582 --- /dev/null +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.tsx @@ -0,0 +1,251 @@ +import assert from 'node:assert/strict'; +import { afterEach, test } from 'node:test'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { AstryxLocaleProvider, LocaleProvider } from '@maka/ui'; +import type { GoalArmOutcome } from '../../shared/goal-arm.js'; +import { GoalDialog } from '../../renderer/goal-dialog.js'; + +const originalGlobals = { + document: globalThis.document, + window: globalThis.window, + HTMLElement: globalThis.HTMLElement, + HTMLIFrameElement: globalThis.HTMLIFrameElement, + Event: globalThis.Event, + Node: globalThis.Node, + CSS: globalThis.CSS, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + cancelAnimationFrame: globalThis.cancelAnimationFrame, + IS_REACT_ACT_ENVIRONMENT: (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }) + .IS_REACT_ACT_ENVIRONMENT, +}; + +let mountedRoot: Root | undefined; + +afterEach(async () => { + if (mountedRoot) await act(() => mountedRoot?.unmount()); + mountedRoot = undefined; + Object.assign(globalThis, originalGlobals); +}); + +test('ignores a stale Goal arm result after switching Sessions', async () => { + const first = deferred(); + const calls: string[] = []; + const harness = installGoalDialog(async (sessionId) => { + calls.push(sessionId); + return first.promise; + }); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + assert.deepEqual(calls, ['session-1']); + + await harness.render('session-2'); + first.resolve({ kind: 'reconciliation_unavailable' }); + await act(async () => { + await first.promise; + await Promise.resolve(); + }); + + assert.doesNotMatch(harness.document.body.textContent, /cannot be confirmed/); + assert.equal(harness.closed, 0); +}); + +test('ignores a stale Goal arm rejection after closing and reopening', async () => { + const first = deferred(); + const harness = installGoalDialog(async () => first.promise); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + + await harness.render(undefined); + await harness.render('session-1'); + first.reject(new Error('old rejection')); + await act(async () => { + await first.promise.catch(() => undefined); + await Promise.resolve(); + }); + + assert.doesNotMatch(harness.document.body.textContent, /goal could not be set/i); + assert.equal(harness.document.querySelector('textarea')?.hasAttribute('disabled'), false); + assert.equal(harness.closed, 0); +}); + +test('closes only for armed and locks reconciled state until reopen', async () => { + const goal = goalState(); + let outcome: GoalArmOutcome = { + kind: 'reconciled', + currentGoal: null, + matchesRequestedState: false, + }; + const harness = installGoalDialog(async () => outcome); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + + assert.equal(harness.closed, 0); + assert.match(harness.document.body.textContent, /no Goal was found/); + assert.equal(harness.document.querySelector('textarea')?.hasAttribute('disabled'), true); + assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), true); + + await harness.render(undefined); + await harness.render('session-1'); + assert.doesNotMatch(harness.document.body.textContent, /no Goal was found/); + assert.equal(harness.document.querySelector('textarea')?.hasAttribute('disabled'), false); + + outcome = { kind: 'armed', goal }; + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + assert.equal(harness.closed, 1); +}); + +test('keeps the Goal form editable after a deterministic rejection', async () => { + const harness = installGoalDialog(async () => { + throw new Error('Goal already exists'); + }); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + + assert.equal(harness.closed, 0); + assert.match(harness.document.body.textContent, /goal could not be set/i); + assert.equal(harness.document.querySelector('textarea')?.hasAttribute('disabled'), false); + assert.equal(findButton(harness.document, 'Start').hasAttribute('disabled'), false); +}); + +function installGoalDialog( + arm: (sessionId: string) => Promise, +) { + const parsed = parseHTML('
'); + const { document, window } = parsed; + const matchMedia = (media: string) => ({ + matches: false, + media, + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent: () => false, + }); + Object.assign(window, { matchMedia, scrollTo() {} }); + Object.assign(window.HTMLElement.prototype, { + showModal(this: HTMLElement) { + this.setAttribute('open', ''); + }, + close(this: HTMLElement) { + this.removeAttribute('open'); + }, + }); + Object.assign(globalThis, { + document, + window, + matchMedia, + HTMLElement: window.HTMLElement, + HTMLIFrameElement: window.HTMLIFrameElement ?? class HTMLIFrameElement {}, + Event: window.Event, + Node: window.Node, + CSS: { escape: (value: string) => value }, + requestAnimationFrame: (callback: FrameRequestCallback) => setTimeout(callback, 0), + cancelAnimationFrame: (handle: number) => clearTimeout(handle), + IS_REACT_ACT_ENVIRONMENT: true, + }); + (window as unknown as { maka: unknown }).maka = { + goal: { arm }, + }; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoot = root; + let closed = 0; + return { + document, + get closed() { + return closed; + }, + async render(sessionId: string | undefined) { + await act(async () => { + root.render( + createElement(LocaleProvider, { + locale: 'en', + children: createElement(AstryxLocaleProvider, { + children: createElement(GoalDialog, { + sessionId, + onClose: () => { + closed += 1; + }, + }), + }), + }), + ); + await Promise.resolve(); + }); + }, + }; +} + +async function setInputValue( + document: Document, + selector: string, + value: string, +): Promise { + const input = document.querySelector(selector) as HTMLInputElement | null; + assert.ok(input, `missing input: ${selector}`); + await act(async () => { + input.value = value; + const propsKey = Object.keys(input).find((key) => key.startsWith('__reactProps$')); + assert.ok(propsKey, 'missing React props on input'); + const props = (input as unknown as Record)[propsKey] as { + onChange?: (event: { target: HTMLInputElement; defaultPrevented: boolean }) => void; + }; + assert.ok(props.onChange, 'missing React change handler'); + props.onChange({ target: input, defaultPrevented: false }); + await Promise.resolve(); + }); +} + +async function clickButton(document: Document, label: string): Promise { + const button = findButton(document, label); + await act(async () => { + button.click(); + await Promise.resolve(); + }); +} + +function findButton(document: Document, label: string): HTMLButtonElement { + const button = [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label, + ) as HTMLButtonElement | undefined; + assert.ok(button, `missing button: ${label}`); + return button; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((accept, decline) => { + resolve = accept; + reject = decline; + }); + return { promise, resolve, reject }; +} + +function goalState() { + return { + id: 'goal-1', + revision: 1, + sessionId: 'session-1', + condition: 'Finish session one', + status: 'active' as const, + setAt: 1, + iterations: 0, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 0, + tokensBaselinePending: false, + }; +} diff --git a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 7aa71b03df..90273f198e 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts @@ -705,6 +705,52 @@ test('retries Goal clear only while the same Goal generation remains active', as ); }); +test('arms a Goal in one request and reports a conflicting Goal instead of retrying', async () => { + const armed = goalProjection(0); + const { client, requests } = clientWithResponses([ + { sessionId: 'session-1', goal: armed }, + ]); + + const result = await client.armGoal({ + sessionId: 'session-1', + condition: 'All tests pass', + maxIterations: 20, + tokenBudget: null, + }); + + assert.deepEqual(result, { sessionId: 'session-1', goal: armed }); + assert.deepEqual( + requests.filter(({ operation }) => operation === 'goal.arm').map(({ input }) => input), + [ + { + sessionId: 'session-1', + condition: 'All tests pass', + maxIterations: 20, + tokenBudget: null, + }, + ], + ); + + // Arming names no revision, so a conflict is an answer for the user — the + // Session already has a Goal — not a stale read to refresh and re-send. + const conflicted = clientWithResponses([ + new RuntimeHostOperationError('goal.arm', 'operation_conflict', 'Goal already set'), + ]); + await assert.rejects( + conflicted.client.armGoal({ + sessionId: 'session-1', + condition: 'All tests pass', + maxIterations: null, + tokenBudget: null, + }), + /Goal already set/, + ); + assert.equal( + conflicted.requests.filter(({ operation }) => operation === 'goal.arm').length, + 1, + ); +}); + test('controlGoalWithRetry applies pause/resume with the queried revision', async () => { const active = goalProjection(1); const paused = { ...goalProjection(2), status: 'paused' as const, pausedAt: 5 }; diff --git a/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts index 7cb9dfb738..9d7fbdcc13 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-reconnecting-ipc-main.test.ts @@ -9,7 +9,212 @@ import { readWithFallback, tryReconnectableReadResult, } from "../ipc-reconnect-policy.js"; -import { RuntimeHostReconnectingIpcMain } from "../runtime-host-reconnecting-ipc-main.js"; +import * as ipcReconnectPolicy from "../ipc-reconnect-policy.js"; +import { + RuntimeHostReconnectingIpcMain, + RuntimeHostTargetChangedError, +} from "../runtime-host-reconnecting-ipc-main.js"; + +test("classifies only dispatched control connection loss for reconciliation", () => { + const predicate = ( + ipcReconnectPolicy as typeof ipcReconnectPolicy & { + isDispatchedControlConnectionLoss?: (error: unknown) => boolean; + } + ).isDispatchedControlConnectionLoss; + assert.equal(typeof predicate, "function"); + assert.equal( + predicate?.( + new RuntimeHostRequestInterruptedError( + "goal.arm", + "control", + "dispatched", + "connection_lost", + ), + ), + true, + ); + for (const error of [ + new RuntimeHostRequestInterruptedError( + "goal.arm", + "control", + "not_dispatched", + "connection_lost", + ), + new RuntimeHostRequestInterruptedError( + "goal.arm", + "control", + "dispatched", + "timeout", + ), + new RuntimeHostRequestInterruptedError( + "goal.query", + "query", + "dispatched", + "connection_lost", + ), + new RuntimeHostRequestInterruptedError( + "web-search.execute", + "command", + "dispatched", + "connection_lost", + ), + new Error("ordinary failure"), + ]) { + assert.equal(predicate?.(error), false); + } +}); + +test("reconciles a dispatched control on a replacement without replaying it", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc); + const firstTarget = router.createTarget("target-a") as ReconciledControlTarget; + assert.equal(typeof firstTarget.handleReconciledControl, "function"); + let dispatches = 0; + firstTarget.handleReconciledControl("goal:arm", { + dispatch: async () => { + dispatches += 1; + return { + kind: "reconcile", + context: { condition: "All tests pass" }, + }; + }, + reconcile: async () => assert.fail("The closed candidate must not reconcile"), + }); + router.activate("target-a"); + + const arming = ipc.invoke("goal:arm", scope("target-a")); + await new Promise((resolve) => setImmediate(resolve)); + firstTarget.removeHandler("goal:arm"); + const replacementTarget = router.createTarget("target-a") as ReconciledControlTarget; + let reconciliations = 0; + replacementTarget.handleReconciledControl("goal:arm", { + dispatch: async () => assert.fail("The mutation must not be replayed"), + reconcile: async (context) => { + reconciliations += 1; + assert.deepEqual(context, { condition: "All tests pass" }); + return { kind: "reconciled", currentGoal: "goal-1" }; + }, + }); + + assert.deepEqual(await arming, { + kind: "reconciled", + currentGoal: "goal-1", + }); + assert.equal(dispatches, 1); + assert.equal(reconciliations, 1); + router.close(); +}); + +test("retries only reconciliation when its replacement connection is lost", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc); + const firstTarget = router.createTarget("target-a") as ReconciledControlTarget; + let dispatches = 0; + firstTarget.handleReconciledControl("goal:arm", { + dispatch: async () => { + dispatches += 1; + return { kind: "reconcile", context: { sessionId: "session-1" } }; + }, + reconcile: async () => assert.fail("The closed candidate must not reconcile"), + }); + router.activate("target-a"); + + const arming = ipc.invoke("goal:arm", scope("target-a")); + const settled = arming.then( + (value) => ({ ok: true as const, value }), + (error: unknown) => ({ ok: false as const, error }), + ); + await new Promise((resolve) => setImmediate(resolve)); + firstTarget.removeHandler("goal:arm"); + const failedTarget = router.createTarget("target-a") as ReconciledControlTarget; + const reconciliationEntered = deferred(); + const failReconciliation = deferred(); + failedTarget.handleReconciledControl("goal:arm", { + dispatch: async () => assert.fail("The mutation must not be replayed"), + reconcile: async () => { + reconciliationEntered.resolve(); + await failReconciliation.promise; + throw new RuntimeHostRequestInterruptedError( + "goal.query", + "query", + "dispatched", + "connection_lost", + ); + }, + }); + await reconciliationEntered.promise; + failReconciliation.resolve(); + await new Promise((resolve) => setImmediate(resolve)); + failedTarget.removeHandler("goal:arm"); + const recoveredTarget = router.createTarget("target-a") as ReconciledControlTarget; + let reconciliations = 0; + recoveredTarget.handleReconciledControl("goal:arm", { + dispatch: async () => assert.fail("The mutation must not be replayed"), + reconcile: async (context) => { + reconciliations += 1; + assert.deepEqual(context, { sessionId: "session-1" }); + return { kind: "reconciled", currentGoal: "goal-1" }; + }, + }); + + assert.deepEqual(await settled, { + ok: true, + value: { kind: "reconciled", currentGoal: "goal-1" }, + }); + assert.equal(dispatches, 1); + assert.equal(reconciliations, 1); + router.close(); +}); + +test("never reconciles a control through a different target epoch", async () => { + const ipc = ipcHarness(); + const router = new RuntimeHostReconnectingIpcMain(ipc); + const firstTarget = router.createTarget("target-a") as ReconciledControlTarget; + let dispatches = 0; + firstTarget.handleReconciledControl("goal:arm", { + dispatch: async () => { + dispatches += 1; + return { kind: "reconcile", context: { sessionId: "session-1" } }; + }, + reconcile: async () => assert.fail("The closed candidate must not reconcile"), + }); + router.activate("target-a"); + + const arming = ipc.invoke("goal:arm", scope("target-a")); + const settled = arming.then( + () => ({ settled: true }), + (error: unknown) => ({ settled: true, error }), + ); + await new Promise((resolve) => setImmediate(resolve)); + firstTarget.removeHandler("goal:arm"); + const otherTarget = router.createTarget("target-b") as ReconciledControlTarget; + let otherReconciliations = 0; + otherTarget.handleReconciledControl("goal:arm", { + dispatch: async () => assert.fail("The mutation must not be replayed"), + reconcile: async () => { + otherReconciliations += 1; + return { kind: "reconciled" }; + }, + }); + router.activate("target-b"); + const pending = Promise.race([ + settled, + new Promise<{ settled: false }>((resolve) => + setImmediate(() => resolve({ settled: false })), + ), + ]); + assert.deepEqual(await pending, { settled: false }); + + router.deactivate("target-a"); + const result = await settled; + assert.equal(result.settled, true); + assert.ok( + "error" in result && result.error instanceof RuntimeHostTargetChangedError, + ); + assert.equal(dispatches, 1); + assert.equal(otherReconciliations, 0); + router.close(); +}); test("holds an invocation across a Runtime Host candidate replacement", async () => { const ipc = ipcHarness(); @@ -257,6 +462,18 @@ test("read adapters project ordinary failures without hiding reconnectable failu type IpcHandler = Parameters[1]; +type ReconciledControlTarget = ReturnType< + RuntimeHostReconnectingIpcMain["createTarget"] +> & { + handleReconciledControl( + channel: string, + handlers: { + dispatch: IpcHandler; + reconcile: (context: unknown, ...args: Parameters) => Promise; + }, + ): void; +}; + function ipcHarness() { const handlers = new Map(); return { diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts index aa09bf9034..41749a3e68 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts @@ -5,7 +5,18 @@ import { projectDeepResearchClientProgress } from '@maka/core/deep-research-clie import { type DeepResearchRun } from '@maka/core/deep-research-run'; import { type PlanSessionState } from '@maka/core/plan'; import { type ShellRunUpdate } from '@maka/core/events'; -import { encodeDeepResearchSnapshot } from '@maka/runtime-host/protocol'; +import { + encodeDeepResearchSnapshot, + type GoalProjection, +} from '@maka/runtime-host/protocol'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; +import type { + ReconciledControlHandlers, + ReconnectableReadIpcMain, +} from '../ipc-reconnect-policy.js'; import { projectEmbeddedDeepResearch, projectHostedDeepResearch, @@ -17,6 +28,288 @@ import { type DomainClient = RuntimeHostSessionDomainsIpcDeps['client']; +test('goal:arm reconciles a lost dispatched response without dispatching again', async () => { + const previous = goalProjection({ goalId: 'goal-old', condition: 'Old goal' }); + const requested = { + condition: ' Finish the adapter ', + maxIterations: 20, + tokenBudget: 1_000, + }; + let armCalls = 0; + const firstIpc = reconciledIpcHarness(); + registerDomainsIpc( + { + client: domainClient({ + queryGoal: async () => ({ sessionId: 'session-1', goal: previous }), + armGoal: async () => { + armCalls += 1; + throw new RuntimeHostRequestInterruptedError( + 'goal.arm', + 'control', + 'dispatched', + 'connection_lost', + ); + }, + }), + emitModeChanged() {}, + }, + firstIpc, + ); + + const step = await firstIpc.dispatch('goal:arm', 'session-1', requested); + assert.equal((step as { kind: string }).kind, 'reconcile'); + + const replacementIpc = reconciledIpcHarness(); + registerDomainsIpc( + { + client: domainClient({ + queryGoal: async () => ({ + sessionId: 'session-1', + goal: goalProjection({ condition: 'Finish the adapter' }), + }), + armGoal: async () => assert.fail('replacement must never re-arm the Goal'), + }), + emitModeChanged() {}, + }, + replacementIpc, + ); + + assert.deepEqual( + await replacementIpc.reconcile( + 'goal:arm', + (step as { context: unknown }).context, + 'session-1', + requested, + ), + { + kind: 'reconciled', + currentGoal: { + id: 'goal-1', + revision: 3, + sessionId: 'session-1', + condition: 'Finish the adapter', + status: 'active', + setAt: 1, + iterations: 2, + maxIterations: 20, + consecutiveNoProgress: 0, + blockCap: 8, + tokenBudget: 1_000, + tokensAtStart: 0, + tokensNow: 120, + tokensBaselinePending: false, + }, + matchesRequestedState: true, + }, + ); + assert.equal(armCalls, 1); +}); + +test('goal:arm reconciliation reports different, missing, and unavailable authority', async () => { + const firstIpc = reconciledIpcHarness(); + registerDomainsIpc( + { + client: domainClient({ + queryGoal: async () => ({ + sessionId: 'session-1', + goal: goalProjection({ goalId: 'goal-old', condition: 'Old goal' }), + }), + armGoal: async () => { + throw new RuntimeHostRequestInterruptedError( + 'goal.arm', + 'control', + 'dispatched', + 'connection_lost', + ); + }, + }), + emitModeChanged() {}, + }, + firstIpc, + ); + const step = await firstIpc.dispatch('goal:arm', 'session-1', { + condition: 'Default budget goal', + }); + const context = (step as { context: unknown }).context; + + const reconcileWith = async ( + queryGoal: DomainClient['queryGoal'], + ): Promise => { + const ipc = reconciledIpcHarness(); + registerDomainsIpc( + { + client: domainClient({ queryGoal }), + emitModeChanged() {}, + }, + ipc, + ); + return ipc.reconcile('goal:arm', context, 'session-1', { + condition: 'Default budget goal', + }); + }; + + const oldGoal = goalProjection({ + goalId: 'goal-old', + condition: 'Default budget goal', + maxIterations: 50, + tokenBudget: null, + }); + assert.deepEqual( + await reconcileWith(async () => ({ sessionId: 'session-1', goal: oldGoal })), + { + kind: 'reconciled', + currentGoal: { + id: 'goal-old', + revision: 3, + sessionId: 'session-1', + condition: 'Default budget goal', + status: 'active', + setAt: 1, + iterations: 2, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 120, + tokensBaselinePending: false, + }, + matchesRequestedState: false, + }, + ); + assert.deepEqual( + await reconcileWith(async () => ({ sessionId: 'session-1', goal: null })), + { kind: 'reconciled', currentGoal: null, matchesRequestedState: false }, + ); + assert.deepEqual( + await reconcileWith(async () => { + throw new Error('query unavailable'); + }), + { kind: 'reconciliation_unavailable' }, + ); + await assert.rejects( + reconcileWith(async () => { + throw new RuntimeHostRequestInterruptedError( + 'goal.query', + 'query', + 'dispatched', + 'connection_lost', + ); + }), + RuntimeHostRequestInterruptedError, + ); +}); + +test('goal:arm preserves deterministic and non-dispatched rejection semantics', async () => { + const errors = [ + new RuntimeHostRequestInterruptedError( + 'goal.arm', + 'control', + 'not_dispatched', + 'connection_lost', + ), + new RuntimeHostRequestInterruptedError( + 'goal.arm', + 'control', + 'dispatched', + 'timeout', + ), + new RuntimeHostOperationError( + 'goal.arm', + 'host_draining', + 'Runtime Host is draining', + ), + ]; + for (const expected of errors) { + const ipc = ipcHarness(); + let armCalls = 0; + registerDomainsIpc( + { + client: domainClient({ + queryGoal: async () => ({ sessionId: 'session-1', goal: null }), + armGoal: async () => { + armCalls += 1; + throw expected; + }, + }), + emitModeChanged() {}, + }, + ipc, + ); + await assert.rejects( + ipc.invoke('goal:arm', 'session-1', { condition: 'Finish' }), + (actual) => actual === expected, + ); + assert.equal(armCalls, 1); + } +}); + +test('goal:arm takes the Session from the scoped channel and refuses any other key', async () => { + const armed: unknown[] = []; + const client = domainClient({ + queryGoal: async (sessionId) => ({ sessionId, goal: null }), + armGoal: async (input) => { + armed.push(input); + return { sessionId: input.sessionId, goal: goalProjection() }; + }, + }); + const ipc = ipcHarness(); + registerDomainsIpc({ client, emitModeChanged() {} }, ipc); + + const outcome = await ipc.invoke('goal:arm', 'session-1', { + condition: 'Finish the adapter', + maxIterations: 20, + tokenBudget: 1_000, + }); + assert.deepEqual(armed, [ + { + sessionId: 'session-1', + condition: 'Finish the adapter', + maxIterations: 20, + tokenBudget: 1_000, + }, + ]); + assert.equal( + (outcome as { kind: string; goal: { id: string } }).kind, + 'armed', + ); + assert.equal( + (outcome as { kind: string; goal: { id: string } }).goal.id, + 'goal-1', + ); + + // Omitted budgets are "not chosen", which the Host reads as its defaults. + await ipc.invoke('goal:arm', 'session-1', { condition: 'Finish the adapter' }); + assert.deepEqual(armed[1], { + sessionId: 'session-1', + condition: 'Finish the adapter', + maxIterations: null, + tokenBudget: null, + }); + + await assert.rejects(ipc.invoke('goal:arm', 'session-1', { condition: ' ' })); + await assert.rejects( + ipc.invoke('goal:arm', 'session-1', { condition: 'Finish', maxIterations: 0 }), + ); + await assert.rejects(ipc.invoke('goal:arm', 'session-1', 'not-an-object')); + + // Any key this frame does not carry is a caller mistake. Dropping it would + // send the Host a frame the caller did not write, so it is refused instead. + await assert.rejects( + ipc.invoke('goal:arm', 'session-1', { condition: 'Finish', blockCap: 5 }), + /Invalid Goal arm input/, + ); + // The Session is one of those keys: it comes from the scoped channel, so a + // renderer-side Session id cannot redirect the operation even by matching. + await assert.rejects( + ipc.invoke('goal:arm', 'session-1', { + sessionId: 'session-somewhere-else', + condition: 'Finish', + }), + /Invalid Goal arm input/, + ); + assert.equal(armed.length, 2); +}); + test('adapts Host Goal, Task, Deep Research, and Resource projections', async () => { const controls: unknown[] = []; const client = domainClient({ @@ -720,6 +1013,7 @@ function domainClient(overrides: Partial): DomainClient { throw new Error('Unexpected domain operation'); }; return { + armGoal: unavailable, clearGoal: unavailable, acquireRuntimeResourceController: unavailable, controlPlan: unavailable, @@ -742,7 +1036,13 @@ function domainClient(overrides: Partial): DomainClient { } as DomainClient; } -function goalProjection() { +function goalProjection( + overrides: Partial = {}, +): GoalProjection { + return { ...baseGoalProjection(), ...overrides }; +} + +function baseGoalProjection() { return { goalId: 'goal-1', revision: 3, @@ -878,10 +1178,49 @@ function ipcHarness() { }; } +function reconciledIpcHarness() { + const ordinaryHandlers = new Map(); + const controlHandlers = new Map< + string, + ReconciledControlHandlers + >(); + return { + handle(channel: string, handler: IpcHandler) { + ordinaryHandlers.set(channel, handler); + }, + handleReconciledControl( + channel: string, + handlers: ReconciledControlHandlers, + ) { + controlHandlers.set( + channel, + handlers as unknown as ReconciledControlHandlers, + ); + }, + async dispatch(channel: string, ...args: unknown[]): Promise { + const handlers = controlHandlers.get(channel); + assert.ok(handlers, `missing reconciled control: ${channel}`); + return handlers.dispatch({} as never, ...args); + }, + async reconcile( + channel: string, + context: unknown, + ...args: unknown[] + ): Promise { + const handlers = controlHandlers.get(channel); + assert.ok(handlers, `missing reconciled control: ${channel}`); + return handlers.reconcile(context, {} as never, ...args); + }, + } satisfies ReconnectableReadIpcMain & { + dispatch(channel: string, ...args: unknown[]): Promise; + reconcile(channel: string, context: unknown, ...args: unknown[]): Promise; + }; +} + function registerDomainsIpc( deps: Omit & Partial>, - ipcMain: Pick, + ipcMain: ReconnectableReadIpcMain, ) { return registerRuntimeHostSessionDomainsIpc( { diff --git a/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts new file mode 100644 index 0000000000..bba8c3f5f2 --- /dev/null +++ b/apps/desktop/src/main/__tests__/session-mode-ipc-main.test.ts @@ -0,0 +1,119 @@ +/** + * Plan and orchestration are two Session fields with two lifetimes, so they + * are two channels here, and each writes only its own field. A Plan excursion + * that cleared the orchestration default would lose it for the execution the + * plan was written for — Runtime leaves Plan by itself on approval, and the + * default has to still be there when it does. + */ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import type { IpcMain } from 'electron'; +import type { DesktopSessionConfigurationPatch } from '../runtime-host-client.js'; +import { + registerRuntimeHostSessionCatalogIpc, + type RuntimeHostSessionCatalogIpcDeps, +} from '../runtime-host-session-catalog-ipc-main.js'; + +type Handler = (event: unknown, ...args: unknown[]) => unknown; + +/** Only the fields `toDesktopHostSessionSummary` reads back out. */ +function projection(sessionId: string) { + return { + id: sessionId, + revision: 1, + workspace: { hostCwd: '/tmp/session', target: { kind: 'path' as const } }, + name: 'Session', + isFlagged: false, + isArchived: false, + labels: [], + status: 'active' as const, + createdAt: 1, + lastUsedAt: 1, + backend: 'fake' as const, + llmConnectionSlug: 'fake', + connectionLocked: false, + model: 'fake-model', + permissionMode: 'ask' as const, + collaborationMode: 'agent' as const, + orchestrationMode: 'default' as const, + }; +} + +function harness(patches: DesktopSessionConfigurationPatch[]) { + const handlers = new Map(); + const ipcMain = { + handle(channel: string, handler: Handler) { + handlers.set(channel, handler); + }, + }; + const deps = { + client: { + async updateSessionConfiguration(sessionId: string, patch: DesktopSessionConfigurationPatch) { + patches.push(patch); + return projection(sessionId); + }, + }, + resolveCreateProject: async () => ({}), + emitSessionsChanged() {}, + releaseSessionResources() {}, + sessionCopyCleanup: { recover: async () => ({ cleaned: [], failed: [] }) }, + } as unknown as RuntimeHostSessionCatalogIpcDeps; + registerRuntimeHostSessionCatalogIpc(deps, ipcMain as unknown as IpcMain); + return { + invoke: (channel: string, ...args: unknown[]) => { + const handler = handlers.get(channel); + assert.ok(handler, `missing handler: ${channel}`); + return handler({}, ...args); + }, + channels: handlers, + }; +} + +test('entering or leaving Plan writes the collaboration field alone', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan'); + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'agent'); + + assert.deepEqual(patches, [{ collaborationMode: 'plan' }, { collaborationMode: 'agent' }]); +}); + +test('the orchestration default writes its own field alone', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm'); + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'default'); + + assert.deepEqual(patches, [{ orchestrationMode: 'swarm' }, { orchestrationMode: 'default' }]); +}); + +test('a Plan Session keeps the orchestration default it was carrying', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'swarm'); + await ipc.invoke('sessions:setCollaborationMode', 'session-1', 'plan'); + + // Nothing in the Plan write names `orchestrationMode`, so the merge at the + // Host leaves Swarm standing. Plan strips the tools it needs for as long as + // the excursion lasts; it does not end it. + assert.deepEqual(patches[1], { collaborationMode: 'plan' }); + assert.equal('orchestrationMode' in (patches[1] ?? {}), false); +}); + +test('an unknown mode is refused rather than persisted', async () => { + const patches: DesktopSessionConfigurationPatch[] = []; + const ipc = harness(patches); + + await assert.rejects( + ipc.invoke('sessions:setCollaborationMode', 'session-1', 'swarm') as Promise, + /Invalid collaboration mode/, + ); + await assert.rejects( + ipc.invoke('sessions:setOrchestrationMode', 'session-1', 'plan') as Promise, + /Invalid orchestration mode/, + ); + assert.deepEqual(patches, [], 'nothing reached the Host'); +}); diff --git a/apps/desktop/src/main/ipc-reconnect-policy.ts b/apps/desktop/src/main/ipc-reconnect-policy.ts index a25129f963..a1e5d61a98 100644 --- a/apps/desktop/src/main/ipc-reconnect-policy.ts +++ b/apps/desktop/src/main/ipc-reconnect-policy.ts @@ -8,8 +8,28 @@ import { HOST_OPERATION_SPECS } from "@maka/runtime-host/protocol"; export type IpcHandler = Parameters[1]; +export type ReconciledControlStep = + | { readonly kind: "completed"; readonly value: Result } + | { readonly kind: "reconcile"; readonly context: Context }; + +export interface ReconciledControlHandlers { + dispatch( + event: Parameters[0], + ...args: unknown[] + ): Promise>; + reconcile( + context: Context, + event: Parameters[0], + ...args: unknown[] + ): Promise; +} + export interface ReconnectableReadIpcMain extends Pick { handleReconnectableRead?(channel: string, listener: IpcHandler): void; + handleReconciledControl?( + channel: string, + handlers: ReconciledControlHandlers, + ): void; } export function handleReconnectableRead( @@ -24,6 +44,23 @@ export function handleReconnectableRead( } } +export function handleReconciledControl( + ipcMain: ReconnectableReadIpcMain, + channel: string, + handlers: ReconciledControlHandlers, +): void { + if (ipcMain.handleReconciledControl) { + ipcMain.handleReconciledControl(channel, handlers); + return; + } + ipcMain.handle(channel, async (event, ...args) => { + const step = await handlers.dispatch(event, ...args); + return step.kind === "completed" + ? step.value + : handlers.reconcile(step.context, event, ...args); + }); +} + export function isReconnectableReadFailure(error: unknown): boolean { return ( (error instanceof RuntimeHostOperationError && @@ -35,6 +72,15 @@ export function isReconnectableReadFailure(error: unknown): boolean { ); } +export function isDispatchedControlConnectionLoss(error: unknown): boolean { + return ( + error instanceof RuntimeHostRequestInterruptedError && + error.mode === "control" && + error.dispatch === "dispatched" && + error.reason === "connection_lost" + ); +} + export function rethrowReconnectableReadFailure(error: unknown): void { if (isReconnectableReadFailure(error)) throw error; } diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index f17ab168ed..88849b347d 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1183,6 +1183,16 @@ export class DesktopRuntimeHostClient { return this.request("goal.query", { sessionId }); } + /** + * Arm a Goal the user asked for. No optimistic retry loop like `clearGoal`: + * arming names no revision, so there is no stale one to refresh — a Session + * that already has an unfinished Goal fails with `operation_conflict`, and + * that is an answer for the user, not a race to re-run. + */ + armGoal(input: OperationInput<"goal.arm">): Promise> { + return this.request("goal.arm", input); + } + controlGoal( goal: Pick, action: GoalControlAction, diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index f301bef2f8..4bba8053a1 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -50,7 +50,11 @@ import { } from "./runtime-host-session-execution-ipc-main.js"; import { RuntimeHostSessionObservationRegistry } from "./runtime-host-session-observation-registry.js"; import { RuntimeHostSessionObserver } from "./runtime-host-session-observer.js"; -import type { IpcHandler, ReconnectableReadIpcMain } from "./ipc-reconnect-policy.js"; +import type { + IpcHandler, + ReconciledControlHandlers, + ReconnectableReadIpcMain, +} from "./ipc-reconnect-policy.js"; import type { RuntimeHostTargetIpcMain } from "./runtime-host-reconnecting-ipc-main.js"; import { desktopSessionResourceKey, @@ -730,6 +734,41 @@ class ScopedIpcMain implements ReconnectableReadIpcMain { this.#handle(channel, listener, true); } + handleReconciledControl( + channel: string, + handlers: ReconciledControlHandlers, + ): void { + if (this.#closed) + throw new Error("Desktop Runtime Host candidate IPC is closed"); + if (this.#channels.has(channel)) { + throw new Error( + `Desktop Runtime Host candidate registered duplicate IPC: ${channel}`, + ); + } + const scopedHandlers: ReconciledControlHandlers = { + dispatch: (event, scope, ...args) => { + requireDesktopTargetScope(scope, this.scope); + return handlers.dispatch(event, ...args); + }, + reconcile: (context, event, scope, ...args) => { + requireDesktopTargetScope(scope, this.scope); + return handlers.reconcile(context, event, ...args); + }, + }; + if (this.#ipcMain.handleReconciledControl) { + this.#ipcMain.handleReconciledControl(channel, scopedHandlers); + } else { + this.#ipcMain.handle(channel, async (event, scope, ...args) => { + requireDesktopTargetScope(scope, this.scope); + const step = await handlers.dispatch(event, ...args); + return step.kind === "completed" + ? step.value + : handlers.reconcile(step.context, event, ...args); + }); + } + this.#channels.add(channel); + } + #handle(channel: string, listener: IpcHandler, reconnectableRead: boolean): void { if (this.#closed) throw new Error("Desktop Runtime Host candidate IPC is closed"); diff --git a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts index c2e125a7d7..1ee2162c47 100644 --- a/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-reconnecting-ipc-main.ts @@ -2,9 +2,16 @@ import type { IpcMain } from "electron"; import { isReconnectableReadFailure, type IpcHandler, + type ReconciledControlHandlers, type ReconnectableReadIpcMain, } from "./ipc-reconnect-policy.js"; +type ReconcileIpcHandler = ( + context: unknown, + event: Parameters[0], + ...args: unknown[] +) => Promise; + interface HandlerWaiter { readonly epoch: string; readonly resolve: (handler: BoundHandler) => void; @@ -15,11 +22,13 @@ interface BoundHandler { readonly epoch: string; readonly owner: symbol; readonly listener: IpcHandler; + readonly reconcile?: ReconcileIpcHandler; } interface HandlerSlot { readonly waiters: Set; readonly reconnectableRead: boolean; + readonly reconciledControl: boolean; readonly handlers: Map; } @@ -60,9 +69,11 @@ export class RuntimeHostReconnectingIpcMain { epoch, isActive: () => this.#activeEpochs.has(epoch), handle: (channel, listener) => - this.#handle(epoch, owner, channel, listener, false), + this.#handle(epoch, owner, channel, listener, false, false), handleReconnectableRead: (channel, listener) => - this.#handle(epoch, owner, channel, listener, true), + this.#handle(epoch, owner, channel, listener, true, false), + handleReconciledControl: (channel, handlers) => + this.#handleReconciledControl(epoch, owner, channel, handlers), removeHandler: (channel) => this.#removeHandler(owner, channel), }; } @@ -101,6 +112,8 @@ export class RuntimeHostReconnectingIpcMain { channel: string, listener: IpcHandler, reconnectableRead: boolean, + reconciledControl: boolean, + reconcile?: ReconcileIpcHandler, ): void { if (this.#closed) throw new Error("Desktop Runtime Host IPC router is closed"); let slot = this.#slots.get(channel); @@ -109,6 +122,7 @@ export class RuntimeHostReconnectingIpcMain { handlers: new Map(), waiters: new Set(), reconnectableRead, + reconciledControl, }; this.#ipcMain.handle(channel, (event, ...args) => this.#dispatch(created, event, args), @@ -116,13 +130,16 @@ export class RuntimeHostReconnectingIpcMain { this.#slots.set(channel, created); slot = created; } - if (slot.reconnectableRead !== reconnectableRead) { + if ( + slot.reconnectableRead !== reconnectableRead || + slot.reconciledControl !== reconciledControl + ) { throw new Error(`Desktop Runtime Host IPC policy changed: ${channel}`); } if (slot.handlers.has(epoch)) { throw new Error(`Desktop Runtime Host IPC handler already exists: ${channel}`); } - const handler = { epoch, owner, listener }; + const handler = { epoch, owner, listener, ...(reconcile ? { reconcile } : {}) }; slot.handlers.set(epoch, handler); for (const waiter of [...slot.waiters]) { if (waiter.epoch !== epoch) continue; @@ -131,6 +148,23 @@ export class RuntimeHostReconnectingIpcMain { } } + #handleReconciledControl( + epoch: string, + owner: symbol, + channel: string, + handlers: ReconciledControlHandlers, + ): void { + this.#handle( + epoch, + owner, + channel, + handlers.dispatch as IpcHandler, + false, + true, + handlers.reconcile as unknown as ReconcileIpcHandler, + ); + } + #removeHandler(owner: symbol, channel: string): void { const slot = this.#slots.get(channel); if (!slot) return; @@ -147,22 +181,43 @@ export class RuntimeHostReconnectingIpcMain { const epoch = this.#requireTargetEpoch(args[0]); let handler = slot.handlers.get(epoch); if (!handler) handler = await this.#waitForHandler(slot, epoch); + let reconciliationContext: unknown; + let reconciling = false; while (true) { try { - const result = await handler.listener(event, ...args); + const result = reconciling + ? await requireReconcileHandler(handler)(reconciliationContext, event, ...args) + : await handler.listener(event, ...args); this.#assertActive(epoch); - if (slot.reconnectableRead && slot.handlers.get(epoch) !== handler) { + if ( + (slot.reconnectableRead || reconciling) && + slot.handlers.get(epoch) !== handler + ) { + handler = await this.#waitForHandler(slot, epoch, handler); + continue; + } + if (slot.reconciledControl && !reconciling) { + const step = requireReconciledControlStep(result); + if (step.kind === "completed") return step.value; + reconciliationContext = step.context; + reconciling = true; handler = await this.#waitForHandler(slot, epoch, handler); continue; } return result; } catch (error) { this.#assertActive(epoch); - if (slot.reconnectableRead && slot.handlers.get(epoch) !== handler) { + if ( + (slot.reconnectableRead || reconciling) && + slot.handlers.get(epoch) !== handler + ) { handler = await this.#waitForHandler(slot, epoch, handler); continue; } - if (!slot.reconnectableRead || !isReconnectableReadFailure(error)) { + if ( + (!slot.reconnectableRead && !reconciling) || + !isReconnectableReadFailure(error) + ) { throw error; } handler = await this.#waitForHandler(slot, epoch, handler); @@ -219,3 +274,25 @@ export class RuntimeHostReconnectingIpcMain { } } } + +function requireReconcileHandler(handler: BoundHandler): ReconcileIpcHandler { + if (!handler.reconcile) { + throw new Error("Desktop Runtime Host reconciled control handler is unavailable"); + } + return handler.reconcile; +} + +function requireReconciledControlStep( + value: unknown, +): { readonly kind: "completed"; readonly value: unknown } | { + readonly kind: "reconcile"; + readonly context: unknown; +} { + if (!value || typeof value !== "object") { + throw new Error("Desktop Runtime Host reconciled control returned an invalid step"); + } + const step = value as { kind?: unknown; value?: unknown; context?: unknown }; + if (step.kind === "completed") return { kind: "completed", value: step.value }; + if (step.kind === "reconcile") return { kind: "reconcile", context: step.context }; + throw new Error("Desktop Runtime Host reconciled control returned an invalid step"); +} diff --git a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts index 96f2169258..304d43e86a 100644 --- a/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-catalog-ipc-main.ts @@ -157,6 +157,12 @@ export function registerRuntimeHostSessionCatalogIpc( if (!isPermissionMode(mode)) throw new Error(`Invalid permission mode: ${String(mode)}`); return updateConfiguration(deps, sessionId, { permissionMode: mode }, 'mode-change'); }); + // Two fields, two channels, one field each. Plan is a temporary + // collaboration excursion that Runtime ends by itself on approval or + // abandonment; orchestration is the Session's standing default for how a + // turn fans out. Runtime resolves the overlap by stripping the subagent and + // agent-graph tools while planning, and validates the two independently, so + // neither channel has any business writing the other's field. ipcMain.handle( 'sessions:setCollaborationMode', async (_event, sessionId: string, mode: unknown) => { diff --git a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index cb4b9b5d9b..a409954d83 100644 --- a/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts @@ -5,7 +5,7 @@ import type { AgentGraphClientSnapshotOptions, AgentGraphOperatorInspection, } from '@maka/runtime/stream-graph-read-model'; -import type { GoalState } from '@maka/runtime/goal-state'; +import { DEFAULT_MAX_ITERATIONS, type GoalState } from '@maka/runtime/goal-state'; import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract'; import type { GoalProjection, @@ -14,9 +14,16 @@ import type { import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client'; import type { DesktopRuntimeHostClient } from './runtime-host-client.js'; import type { RuntimeHostSessionObserver } from './runtime-host-session-observer.js'; +import { + GOAL_ARM_REQUEST_KEYS, + type GoalArmOutcome, +} from '../shared/goal-arm.js'; import { projectHostedDeepResearch } from './deep-research-desktop-projection.js'; import { + handleReconciledControl, handleReconnectableRead, + isDispatchedControlConnectionLoss, + rethrowReconnectableReadFailure, type ReconnectableReadIpcMain, } from './ipc-reconnect-policy.js'; import { @@ -27,6 +34,7 @@ import { type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & Pick< DesktopRuntimeHostClient, + | 'armGoal' | 'clearGoal' | 'controlGoalWithRetry' | 'controlPlan' @@ -94,6 +102,50 @@ export function registerRuntimeHostSessionDomainsIpc( ipcMain.handle('goal:clear', async (_event, sessionId: unknown) => { await deps.client.clearGoal(requiredId(sessionId, 'Session')); }); + handleReconciledControl( + ipcMain, + 'goal:arm', + { + dispatch: async (_event, ...args) => { + const request = canonicalGoalArmRequest(args[0], args[1]); + const previous = await deps.client.queryGoal(request.sessionId); + try { + const result = await deps.client.armGoal(request); + return { + kind: 'completed', + value: { kind: 'armed', goal: toDesktopGoal(result.goal) }, + }; + } catch (error) { + if (!isDispatchedControlConnectionLoss(error)) throw error; + return { + kind: 'reconcile', + context: Object.freeze({ + request, + previousGoal: + previous.goal === null ? null : Object.freeze({ ...previous.goal }), + }), + }; + } + }, + reconcile: async (context) => { + try { + const result = await deps.client.queryGoal(context.request.sessionId); + return { + kind: 'reconciled', + currentGoal: result.goal === null ? null : toDesktopGoal(result.goal), + matchesRequestedState: goalMatchesArmRequest( + result.goal, + context.request, + context.previousGoal, + ), + }; + } catch (error) { + rethrowReconnectableReadFailure(error); + return { kind: 'reconciliation_unavailable' }; + } + }, + }, + ); ipcMain.handle('goal:pause', async (_event, sessionId: unknown) => { await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'pause'); }); @@ -302,6 +354,84 @@ async function refreshRuntimeResources( } } +interface CanonicalGoalArmRequest { + readonly sessionId: string; + readonly condition: string; + readonly maxIterations: number | null; + readonly tokenBudget: number | null; +} + +interface GoalArmReconciliationContext { + readonly request: CanonicalGoalArmRequest; + readonly previousGoal: GoalProjection | null; +} + +function canonicalGoalArmRequest( + sessionId: unknown, + input: unknown, +): CanonicalGoalArmRequest { + const budgets = requireGoalArmBudgets(input); + return Object.freeze({ + sessionId: requiredId(sessionId, 'Session'), + condition: budgets.condition.trim(), + maxIterations: budgets.maxIterations, + tokenBudget: budgets.tokenBudget, + }); +} + +function goalMatchesArmRequest( + current: GoalProjection | null, + request: CanonicalGoalArmRequest, + previous: GoalProjection | null, +): boolean { + return ( + current !== null && + current.goalId !== previous?.goalId && + current.sessionId === request.sessionId && + current.condition === request.condition && + current.maxIterations === (request.maxIterations ?? DEFAULT_MAX_ITERATIONS) && + current.tokenBudget === request.tokenBudget + ); +} + +/** + * The renderer sends what the user typed; the Host owns every bound. This only + * gets the frame into the shape the protocol decodes — numbers stay numbers, + * "not chosen" stays null — so an out-of-range budget is refused once, by the + * Host, instead of being clamped here into something the user did not ask for. + * The Session comes from the scoped IPC argument, not from this frame, so a + * renderer-side Session id can never redirect the operation. + */ +function requireGoalArmBudgets(value: unknown): { + condition: string; + maxIterations: number | null; + tokenBudget: number | null; +} { + if (typeof value !== 'object' || value === null) { + throw new TypeError('Goal arm input must be an object'); + } + const record = value as Record; + if (Object.keys(record).some((key) => !GOAL_ARM_REQUEST_KEYS.includes(key as never))) { + throw new TypeError('Invalid Goal arm input'); + } + if (typeof record.condition !== 'string' || record.condition.trim().length === 0) { + throw new TypeError('Goal condition is required'); + } + return { + condition: record.condition, + maxIterations: optionalCount(record.maxIterations, 'Goal maxIterations'), + tokenBudget: optionalCount(record.tokenBudget, 'Goal tokenBudget'), + }; +} + +function optionalCount(value: unknown, label: string): number | null { + if (value === null || value === undefined) return null; + if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) { + throw new TypeError(`${label} must be a positive integer`); + } + return value; +} + function toDesktopGoal(goal: GoalProjection): GoalState { return { id: goal.goalId, diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3e0ff10b8c..ef0a74c86f 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -594,7 +594,16 @@ export interface MakaBridge { setFlagged(sessionId: string, isFlagged: boolean, options?: { revisionFamily?: boolean }): Promise; rename(sessionId: string, name: string, options?: { revisionFamily?: boolean }): Promise; setPermissionMode(sessionId: string, mode: PermissionMode): Promise; + /** + * Enter or leave Plan — a temporary collaboration excursion Runtime ends + * by itself once a proposal is approved or abandoned. + */ setCollaborationMode(sessionId: string, mode: CollaborationMode): Promise; + /** + * The Session's standing default for how a turn fans out. Independent of + * Plan: different field, different lifetime, and Runtime resolves the + * overlap by stripping the tools Swarm and Graph need while planning. + */ setOrchestrationMode(sessionId: string, mode: OrchestrationMode): Promise; getPlanState(sessionId: string): Promise; subscribePlanChanges(sessionId: string, handler: () => void): () => void; @@ -715,6 +724,15 @@ export interface MakaBridge { goal: { /** The session's current goal (null when none is set). */ get(sessionId: string): Promise; + /** + * Arm a goal for this session. It drives the session from the next turn + * on; arming alone starts nothing. Rejects when the session already has an + * unfinished goal. + */ + arm( + sessionId: string, + goal: import('../shared/goal-arm').GoalArmRequest, + ): Promise; /** Clear the active goal, stopping autonomous continuation. */ clear(sessionId: string): Promise; /** Pause the active goal without spending a model turn. */ diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index f17b8ea656..d066250529 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -80,6 +80,7 @@ import type { UserQuestionResponse } from '@maka/core/user-question'; import type { PermissionMode } from '@maka/core/permission'; import type { CollaborationMode } from '@maka/core/collaboration'; import type { OrchestrationMode } from '@maka/core/orchestration'; + import type { TurnOrchestration, SessionListFilter, RegenerateTurnInput } from '@maka/core/runtime-inputs'; import type { PlanSessionState } from '@maka/core/plan'; import type { SearchErrorReason, SearchRequest, SearchResult } from '@maka/core/search'; @@ -169,6 +170,7 @@ import { requireDesktopTargetScope, type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; +import type { GoalArmOutcome, GoalArmRequest } from '../shared/goal-arm.js'; import { projectDesktopAttachmentRefs, projectDesktopDailyReviewSummary, @@ -1909,6 +1911,9 @@ const makaBridge = { get(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('goal:get', sessionId); }, + arm(sessionId: string, goal: GoalArmRequest): Promise { + return invokeProjectedSessionRuntimeHost('goal:arm', sessionId, goal); + }, clear(sessionId: string): Promise { return invokeSessionRuntimeHost('goal:clear', sessionId); }, diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index d16786aee4..349d13472f 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -13,6 +13,7 @@ import type { ScheduledTask } from '@maka/core/scheduled-task'; import type { ProjectRecord } from '@maka/core/project'; import type { QuoteRef } from '@maka/core/events'; import type { SessionSummary } from '@maka/core/session'; +import type { OrchestrationMode } from '@maka/core/orchestration'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import type { SlashCommandIdForSurface } from '@maka/core/slash-command-catalog'; import type { UiLocale, UiLocalePreference } from '@maka/core/ui-locale'; @@ -225,6 +226,7 @@ function rebaseWorkspaceFileReferences( import { useSettingsModal } from './use-settings-modal'; import { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog'; +import { GoalDialog } from './goal-dialog.js'; import { useSystemUiLocale } from './use-system-ui-locale'; import { isSessionWorkspaceUnavailableError, @@ -417,14 +419,15 @@ function AppShellContent({ removeQuote, clearQuotes, } = useAppShellComposerQuotes({ draftKey: attachmentDraftKey }); + // What a new chat will start with, held the way the Session holds it: a + // Plan toggle and one orchestration value, not one fused choice. const [newChatPlanModeActive, setNewChatPlanModeActive] = useState(false); + const [newChatOrchestrationMode, setNewChatOrchestrationMode] = useState('default'); const [scheduledTaskCreateRequestNonce, setScheduledTaskCreateRequestNonce] = useState(0); const [pendingCollaborationModeBySession, setPendingCollaborationModeBySession] = useState>({}); - const [newChatSwarmModeActive, setNewChatSwarmModeActive] = useState(false); - const [newChatGraphModeActive, setNewChatGraphModeActive] = useState(false); + const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); const [newTaskPermissionChoice, setNewTaskPermissionChoice] = useNewTaskChoice(currentNewTaskDraftKey); - const [pendingOrchestrationModeBySession, setPendingOrchestrationModeBySession] = useState>({}); const [historyLoadPendingSessionId, setHistoryLoadPendingSessionId] = useState(); const [transcriptTurnIndex, setTranscriptTurnIndex] = useState<{ sessionId: string; @@ -757,6 +760,12 @@ function AppShellContent({ // Active autonomous goal for the current session drives the header // kill-switch pill (visible indicator + one-click clear). const activeGoal = useSessionGoal(activeId); + /** + * The Session the Goal dialog is arming, or `undefined` when it is closed. + * Keyed by Session rather than a boolean so switching Sessions while the + * dialog is open can never arm the wrong one. + */ + const [goalDialogSessionId, setGoalDialogSessionId] = useState(); const pendingGoalControlSessionIdsRef = useRef(new Set()); const runGoalControl = useCallback( ( @@ -920,6 +929,8 @@ function AppShellContent({ const pendingTurnActions = turnActionRegistry.keys; const sessionRowActionRegistry = useKeyedPendingRegistry(); const permissionModeChangeRegistry = useKeyedPendingRegistry(); + // One registry per persisted field. The two controls are independent, so a + // Plan transition in flight is no reason to hold the orchestration choice. const collaborationModeChangeRegistry = useKeyedPendingRegistry(); const orchestrationModeChangeRegistry = useKeyedPendingRegistry(); const sessionModelChangeRegistry = useKeyedPendingRegistry(); @@ -1007,17 +1018,30 @@ function AppShellContent({ toastApi, }); - async function setPlanMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - setNewChatPlanModeActive(active); - return; - } + /** + * Enter or leave Plan for one Session — the only path that writes + * `collaborationMode`, and it writes nothing else. + * + * `sessionId` is a parameter rather than a read of `activeIdRef`, because + * this awaits — a Plan-exit confirmation can sit open while the user opens + * another Session, and a re-read partway through would finish the + * transition somewhere else. + * + * Both gates read the Host through `getPlanState`, not the projected mode. + * The projection can be a frame behind; the question "does this discard a + * pending plan proposal" has an authoritative answer and deserves it. + * + * The Session's orchestration default is left exactly as it was. Plan is a + * temporary excursion that Runtime ends by itself once a proposal is + * approved or abandoned, so clearing the default on the way in would lose + * it for the execution the plan was written for. + */ + async function applyPlanMode(active: boolean, sessionId: string): Promise { if (!addPendingSessionAction( sessionId, collaborationModeChangeRegistry.keysRef, setPendingCollaborationModeBySession, - )) return; + )) return false; try { const planState = await window.maka.sessions.getPlanState(sessionId); @@ -1026,7 +1050,7 @@ function AppShellContent({ shellCopy.planModeExecutionActiveTitle, shellCopy.planModeExecutionActiveDescription, ); - return; + return false; } const latestProposal = planState.proposals.find( (proposal) => proposal.proposalId === planState.latestProposalId, @@ -1039,7 +1063,9 @@ function AppShellContent({ cancelLabel: shellCopy.planModeExitCancel, destructive: true, }); - if (!confirmed) return; + if (!confirmed) return false; + // Abandoning the proposal is what leaves Plan: Runtime writes the + // Session back to `agent` itself as part of it. await window.maka.sessions.abandonPlanProposal(sessionId, latestProposal.proposalId); setSessions((current) => current.map((session) => ( session.id === sessionId ? { ...session, collaborationMode: 'agent' } : session @@ -1052,6 +1078,7 @@ function AppShellContent({ setSessions((current) => current.map((session) => session.id === next.id ? next : session)); } await refreshSessions(); + return true; } catch (error) { if (activeIdRef.current === sessionId) { toastApi.error( @@ -1059,6 +1086,7 @@ function AppShellContent({ localizedShellErrorMessage(error, shellCopy.planModeFallback, uiLocale), ); } + return false; } finally { clearPendingSessionAction( sessionId, @@ -1068,13 +1096,17 @@ function AppShellContent({ } } - async function setSwarmMode(active: boolean): Promise { - const sessionId = activeIdRef.current; - if (!sessionId) { - setNewChatSwarmModeActive(active); - if (active) setNewChatGraphModeActive(false); - return true; - } + /** + * Set the Session's standing orchestration default — the only path that + * writes `orchestrationMode`, and it writes nothing else. + * + * One field with three values, so there is nothing to sequence and nothing + * to leave half-applied: Swarm, Graph and off are one write each. + */ + async function applyOrchestrationMode( + mode: OrchestrationMode, + sessionId: string, + ): Promise { if (!addPendingSessionAction( sessionId, orchestrationModeChangeRegistry.keysRef, @@ -1082,18 +1114,15 @@ function AppShellContent({ )) return false; try { - const next = await window.maka.sessions.setOrchestrationMode( - sessionId, - active ? 'swarm' : 'default', - ); + const next = await window.maka.sessions.setOrchestrationMode(sessionId, mode); setSessions((current) => current.map((session) => session.id === next.id ? next : session)); await refreshSessions(); return true; } catch (error) { if (activeIdRef.current === sessionId) { toastApi.error( - shellCopy.swarmModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.swarmModeFallback, uiLocale), + shellCopy.orchestrationModeFailedTitle, + localizedShellErrorMessage(error, shellCopy.orchestrationModeFallback, uiLocale), ); } return false; @@ -1106,42 +1135,40 @@ function AppShellContent({ } } - async function setGraphMode(active: boolean): Promise { + function setPlanMode(active: boolean): Promise { const sessionId = activeIdRef.current; if (!sessionId) { - setNewChatGraphModeActive(active); - if (active) setNewChatSwarmModeActive(false); - return true; + setNewChatPlanModeActive(active); + return Promise.resolve(true); } - if (!addPendingSessionAction( - sessionId, - orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, - )) return false; + if (active === activePlanMode) return Promise.resolve(true); + return applyPlanMode(active, sessionId); + } - try { - const next = await window.maka.sessions.setOrchestrationMode( - sessionId, - active ? 'graph' : 'default', - ); - setSessions((current) => current.map((session) => session.id === next.id ? next : session)); - await refreshSessions(); - return true; - } catch (error) { - if (activeIdRef.current === sessionId) { - toastApi.error( - shellCopy.graphModeFailedTitle, - localizedShellErrorMessage(error, shellCopy.graphModeFallback, uiLocale), - ); - } - return false; - } finally { - clearPendingSessionAction( - sessionId, - orchestrationModeChangeRegistry.keysRef, - setPendingOrchestrationModeBySession, - ); + /** + * The + menu's orchestration choice and the `/swarm` and `/graph` commands + * all land here, so every entry point spells the field the same way. + * + * `/swarm off` means "leave swarm", not "go to default": a Session already + * in Graph has nothing for it to do. + */ + function setOrchestrationMode(mode: OrchestrationMode): Promise { + const sessionId = activeIdRef.current; + if (!sessionId) { + setNewChatOrchestrationMode(mode); + return Promise.resolve(true); } + if (mode === activeOrchestrationMode) return Promise.resolve(true); + return applyOrchestrationMode(mode, sessionId); + } + + function setOrchestrationModeActive( + mode: Exclude, + active: boolean, + ): Promise { + if (active) return setOrchestrationMode(mode); + if (activeOrchestrationMode !== mode) return Promise.resolve(true); + return setOrchestrationMode('default'); } // Handed to ChatView, which calls it with the turns its transcript projection @@ -1291,6 +1318,29 @@ function AppShellContent({ permissionMode: defaultPermissionMode, } : undefined); + // Each control reads its own field. There is nothing to project and nothing + // to keep in sync: a Session in Plan with Swarm as its orchestration default + // says both, because it is both. + const activePlanMode = activeId + ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' + : newChatPlanModeActive; + const activeOrchestrationMode: OrchestrationMode = activeId + ? activeSessionForView?.orchestrationMode ?? 'default' + : newChatOrchestrationMode; + /** + * Why neither mode can be changed right now, if either cannot. Both controls + * write the same Session configuration, so everything that holds one holds + * the other; only "this one is already changing" is per-control. + */ + const modeChangeDisabledReason = activeId && !activeSession + ? shellCopy.modeChangeLoading + : activeStreamingLive + ? shellCopy.modeChangeStreaming + : activeId && turnActive + ? shellCopy.modeChangeRunning + : activeId && activeSessionForView?.status === 'waiting_for_user' + ? shellCopy.modeChangeWaiting + : undefined; const { boundary: activeExecutionBoundary, unreadable: activeExecutionBoundaryUnreadable, @@ -2085,11 +2135,7 @@ function AppShellContent({ pendingNewChatThinkingLevel: newChatThinkingLevel ?? null, newChatPermissionMode: newTaskPermissionMode, newChatCollaborationMode: newChatPlanModeActive ? 'plan' : 'agent', - newChatOrchestrationMode: newChatGraphModeActive - ? 'graph' - : newChatSwarmModeActive - ? 'swarm' - : 'default', + newChatOrchestrationMode: newChatOrchestrationMode, newTaskTarget: newTask.target, }); @@ -2215,9 +2261,7 @@ function AppShellContent({ if (slashCommand?.kind === 'swarm') { const swarmCommand = slashCommand.command; if (swarmCommand.kind === 'status') { - const active = activeIdRef.current - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive; + const active = activeOrchestrationMode === 'swarm'; toastApi.info( active ? shellCopy.swarmModeEnabledTitle : shellCopy.swarmModeDisabledTitle, shellCopy.swarmModeStatusDescription, @@ -2225,7 +2269,7 @@ function AppShellContent({ return true; } if (swarmCommand.kind === 'set_mode') { - const changed = await setSwarmMode(swarmCommand.mode === 'swarm'); + const changed = await setOrchestrationModeActive('swarm', swarmCommand.mode === 'swarm'); if (changed) { toastApi.info( swarmCommand.mode === 'swarm' @@ -2258,9 +2302,7 @@ function AppShellContent({ if (slashCommand?.kind === 'graph') { const graphCommand = slashCommand.command; if (graphCommand.kind === 'status') { - const active = activeIdRef.current - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive; + const active = activeOrchestrationMode === 'graph'; toastApi.info( active ? shellCopy.graphModeEnabledTitle : shellCopy.graphModeDisabledTitle, shellCopy.graphModeStatusDescription, @@ -2272,7 +2314,7 @@ function AppShellContent({ return true; } if (graphCommand.kind === 'set_mode') { - const changed = await setGraphMode(graphCommand.mode === 'graph'); + const changed = await setOrchestrationModeActive('graph', graphCommand.mode === 'graph'); if (changed) { toastApi.info( graphCommand.mode === 'graph' @@ -2665,6 +2707,8 @@ function AppShellContent({ function openNewTaskSurface() { startNewSession(); + // Only Plan resets: a new task starts out of Plan, in whatever + // orchestration the last one was set to. setNewChatPlanModeActive(false); setNavSelection({ section: 'sessions' }); setSearchScrollTarget(null); @@ -3219,58 +3263,41 @@ function AppShellContent({ ? (mode) => setPermissionMode(mode) : undefined } - planModeActive={activeId - ? (activeSessionForView?.collaborationMode ?? 'agent') === 'plan' - : newChatPlanModeActive} - planModePending={activeId ? pendingCollaborationModeBySession[activeId] === true : false} + planModeActive={activePlanMode} + planModePending={activeId + ? pendingCollaborationModeBySession[activeId] === true + : false} planModeDisabledReason={ activeId && pendingCollaborationModeBySession[activeId] === true - ? shellCopy.planModeChanging - : activeStreamingLive - ? shellCopy.planModeStreaming - : activeId && turnActive - ? shellCopy.planModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.planModeWaiting - : undefined - } - onPlanModeChange={setPlanMode} - swarmModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'swarm' - : newChatSwarmModeActive} - swarmModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - swarmModeDisabledReason={ - activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.swarmModeChanging - : activeStreamingLive - ? shellCopy.swarmModeStreaming - : activeId && turnActive - ? shellCopy.swarmModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.swarmModeWaiting - : undefined + ? shellCopy.modeChanging + : modeChangeDisabledReason } - onSwarmModeChange={(active) => { - void setSwarmMode(active); + onPlanModeChange={(active) => { + void setPlanMode(active); }} - graphModeActive={activeId - ? (activeSessionForView?.orchestrationMode ?? 'default') === 'graph' - : newChatGraphModeActive} - graphModePending={activeId ? pendingOrchestrationModeBySession[activeId] === true : false} - graphModeDisabledReason={ + orchestrationMode={activeOrchestrationMode} + orchestrationModePending={activeId + ? pendingOrchestrationModeBySession[activeId] === true + : false} + orchestrationModeDisabledReason={ activeId && pendingOrchestrationModeBySession[activeId] === true - ? shellCopy.graphModeChanging - : activeStreamingLive - ? shellCopy.graphModeStreaming - : activeId && turnActive - ? shellCopy.graphModeRunning - : activeId && activeSessionForView?.status === 'waiting_for_user' - ? shellCopy.graphModeWaiting - : undefined + ? shellCopy.modeChanging + : modeChangeDisabledReason } - onGraphModeChange={(active) => { - void setGraphMode(active); + onOrchestrationModeChange={(mode) => { + void setOrchestrationMode(mode); }} + onSetGoal={ + activeId && activeBoundarySurface.localInteractionAvailable + ? () => setGoalDialogSessionId(activeId) + : undefined + } + goalActive={activeGoal !== null} + goalDisabledReason={ + activeStreamingLive || (activeId && turnActive) + ? shellCopy.goalTurnActive + : undefined + } /> } @@ -3586,6 +3613,10 @@ function AppShellContent({ }} /> + setGoalDialogSessionId(undefined)} + /> max) return { kind: 'invalid' }; + return { kind: 'value', value }; +} + +export function GoalDialog(props: { + /** The Session to arm. `undefined` closes the dialog. */ + sessionId?: string; + onClose(): void; +}) { + const locale = useUiLocale(); + const copy = getShellCopy(locale).goalDialog; + const [condition, setCondition] = useState(''); + const [maxIterationsText, setMaxIterationsText] = useState(''); + const [tokenBudgetText, setTokenBudgetText] = useState(''); + const [arming, setArming] = useState(false); + const [error, setError] = useState(); + const [reconciliation, setReconciliation] = + useState(); + const armGenerationRef = useRef(0); + + // Each opening starts from empty: the previous Session's condition is not a + // useful default for this one, and a stale error would outlive its cause. + useLayoutEffect(() => { + armGenerationRef.current += 1; + if (props.sessionId === undefined) return; + setCondition(''); + setMaxIterationsText(''); + setTokenBudgetText(''); + setError(undefined); + setArming(false); + setReconciliation(undefined); + }, [props.sessionId]); + + const sessionId = props.sessionId; + const maxIterations = readGoalBudget(maxIterationsText, 1, GOAL_MAX_ITERATIONS_LIMIT); + const tokenBudget = readGoalBudget(tokenBudgetText, GOAL_TOKEN_BUDGET_MINIMUM); + const locked = reconciliation !== undefined; + const canSubmit = + condition.trim().length > 0 && + maxIterations.kind !== 'invalid' && + tokenBudget.kind !== 'invalid' && + !arming && + !locked; + + const reconciliationMessage = (() => { + if (!reconciliation) return undefined; + switch (reconciliation.kind) { + case 'matching_goal': + return copy.reconciledMatching( + reconciliation.goal.condition, + copy.statusLabels[reconciliation.goal.status], + ); + case 'different_goal': + return copy.reconciledDifferent( + reconciliation.goal.condition, + copy.statusLabels[reconciliation.goal.status], + ); + case 'no_goal': + return copy.reconciledNoGoal; + case 'unavailable': + return copy.reconciliationUnavailable; + } + })(); + + async function arm(): Promise { + if (!sessionId || !canSubmit) return; + const armGeneration = ++armGenerationRef.current; + setArming(true); + setError(undefined); + try { + const outcome = await window.maka.goal.arm(sessionId, { + condition: condition.trim(), + maxIterations: maxIterations.kind === 'value' ? maxIterations.value : null, + tokenBudget: tokenBudget.kind === 'value' ? tokenBudget.value : null, + }); + if (armGenerationRef.current !== armGeneration) return; + const action = interpretGoalArmOutcome(outcome); + if (action.action === 'close') { + props.onClose(); + } else { + setReconciliation(action.notice); + } + } catch (cause) { + if (armGenerationRef.current !== armGeneration) return; + setError(localizedShellErrorMessage(cause, copy.failedFallback, locale)); + } finally { + if (armGenerationRef.current === armGeneration) setArming(false); + } + } + + return ( + { + if (!open && !arming) props.onClose(); + }} + purpose="form" + width={520} + className="goalDialog" + > + { + if (!open && !arming) props.onClose(); + }} />} + content={ + + + {copy.description} + {reconciliationMessage ? ( + {reconciliationMessage} + ) : null} +