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__/runtime-host-client-operations.test.ts b/apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts index 7aa71b03df..9620780a9a 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 @@ -754,6 +754,52 @@ test('controlGoalWithRetry rethrows a status refusal instead of retrying it away ); }); +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('rejects an invalid sidecar continuation without misclassifying it as revision churn', async () => { const revision = catalogRevision('7'); const { client, requests } = clientWithResponses([ 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..1a9fe9c4b1 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 @@ -17,6 +17,65 @@ import { type DomainClient = RuntimeHostSessionDomainsIpcDeps['client']; +test('goal:arm takes the Session from the scoped channel and refuses any other key', async () => { + const armed: unknown[] = []; + const client = domainClient({ + armGoal: async (input) => { + armed.push(input); + return { sessionId: input.sessionId, goal: goalProjection() }; + }, + }); + const ipc = ipcHarness(); + registerDomainsIpc({ client, emitModeChanged() {} }, ipc); + + const goal = 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((goal as { id: string }).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 +779,7 @@ function domainClient(overrides: Partial): DomainClient { throw new Error('Unexpected domain operation'); }; return { + armGoal: unavailable, clearGoal: unavailable, acquireRuntimeResourceController: unavailable, controlPlan: unavailable, 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-session-domains-ipc-main.ts b/apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts index cb4b9b5d9b..5f8f0dc5b7 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 @@ -14,6 +14,7 @@ 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 } from '../shared/goal-arm.js'; import { projectHostedDeepResearch } from './deep-research-desktop-projection.js'; import { handleReconnectableRead, @@ -27,6 +28,7 @@ import { type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & Pick< DesktopRuntimeHostClient, + | 'armGoal' | 'clearGoal' | 'controlGoalWithRetry' | 'controlPlan' @@ -100,6 +102,13 @@ export function registerRuntimeHostSessionDomainsIpc( ipcMain.handle('goal:resume', async (_event, sessionId: unknown) => { await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'resume'); }); + ipcMain.handle('goal:arm', async (_event, sessionId: unknown, input: unknown) => { + const result = await deps.client.armGoal({ + sessionId: requiredId(sessionId, 'Session'), + ...requireGoalArmBudgets(input), + }); + return toDesktopGoal(result.goal); + }); handleReconnectableRead(ipcMain, 'plan-mode:getState', (_event, sessionId: unknown) => deps.client.getPlanState(requiredId(sessionId, 'Session')), @@ -302,6 +311,44 @@ async function refreshRuntimeResources( } } +/** + * 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 2a9c101915..8f01896c7a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -724,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 60863bf20f..62866f9470 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -170,6 +170,7 @@ import { requireDesktopTargetScope, type DesktopTargetScope, } from '../shared/runtime-host-identity.js'; +import type { GoalArmRequest } from '../shared/goal-arm.js'; import { projectDesktopAttachmentRefs, projectDesktopDailyReviewSummary, @@ -1910,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 a23ef33b52..7b1c952e38 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -227,6 +227,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, @@ -776,6 +777,12 @@ function AppShellContent({ }, [], ); + /** + * 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(); // Set of session ids whose backend / connection is no longer usable — // drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat // header banner). Derivation is pure (see `stale-sessions.ts`) so the @@ -3271,6 +3278,17 @@ function AppShellContent({ onOrchestrationModeChange={(mode) => { void setOrchestrationMode(mode); }} + onSetGoal={ + activeId && activeBoundarySurface.localInteractionAvailable + ? () => setGoalDialogSessionId(activeId) + : undefined + } + goalActive={activeGoal !== null} + goalDisabledReason={ + activeStreamingLive || (activeId && turnActive) + ? shellCopy.goalTurnActive + : undefined + } /> } @@ -3586,6 +3604,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(); + + // 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. + useEffect(() => { + if (props.sessionId === undefined) return; + setCondition(''); + setMaxIterationsText(''); + setTokenBudgetText(''); + setError(undefined); + setArming(false); + }, [props.sessionId]); + + const sessionId = props.sessionId; + const maxIterations = readGoalBudget(maxIterationsText, 1, GOAL_MAX_ITERATIONS_LIMIT); + const tokenBudget = readGoalBudget(tokenBudgetText, GOAL_TOKEN_BUDGET_MINIMUM); + const canSubmit = + condition.trim().length > 0 && + maxIterations.kind !== 'invalid' && + tokenBudget.kind !== 'invalid' && + !arming; + + async function arm(): Promise { + if (!sessionId || !canSubmit) return; + setArming(true); + setError(undefined); + try { + await window.maka.goal.arm(sessionId, { + condition: condition.trim(), + maxIterations: maxIterations.kind === 'value' ? maxIterations.value : null, + tokenBudget: tokenBudget.kind === 'value' ? tokenBudget.value : null, + }); + props.onClose(); + } catch (cause) { + setError(localizedShellErrorMessage(cause, copy.failedFallback, locale)); + } finally { + setArming(false); + } + } + + return ( + { + if (!open && !arming) props.onClose(); + }} + purpose="form" + width={520} + className="goalDialog" + > + { + if (!open && !arming) props.onClose(); + }} />} + content={ + + + {copy.description} +