From 29c3dee29a1c2e3cc5b4f4b20a471ce6a6cc2998 Mon Sep 17 00:00:00 2001 From: AstroHan Date: Tue, 18 Aug 2026 17:17:48 +0800 Subject: [PATCH 01/12] feat(goal): let the user arm a Goal from the composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Goal could only be armed by the model, from inside a Turn, with the GoalSet tool. There was no Host operation for it, no bridge method, and no control anywhere in the product — the user could stop a Goal but never start one. Add `goal.arm` as a real Host operation, next to `goal.query` and `goal.control`, and carry it out to the + menu: - `HostGoalCoordinator#arm` creates the Goal through the same GoalManager the tool uses, under the same Session admission gate, with the same durable record. It schedules nothing: a Goal armed outside a Turn takes hold on the next one, which `beginObservedTurn` binds to the live control lease. A Session that already has an unfinished Goal gets `operation_conflict`. - The two budget ceilings and the token floor move to `@maka/core/goal`, so the protocol codec and the GoalSet tool schema validate against one number instead of two copies of it. - Remote owners are granted `goal.arm` at the same tier as `goal.control`: withholding it would withhold nothing, since a remote owner sends Turns and the model arms its own Goal inside one. The only thing a refusal removes is the explicit path the user can see and stop. - The IPC handler takes the Session from the scoped channel, never from the renderer's frame, and normalizes the budgets without clamping them — an out-of-range value is refused once, by the Host. - The + menu gains one "设定 Goal…" row in its action group, at the same 28px rhythm as the rest, and app-shell opens a dialog that collects the condition and the two budgets. The row explains itself when a Goal is already running or a Turn is in flight. Generated-by: Claude Code --- .../runtime-host-client-operations.test.ts | 46 ++++++ ...time-host-session-domains-ipc-main.test.ts | 45 ++++++ apps/desktop/src/main/runtime-host-client.ts | 15 ++ .../runtime-host-session-domains-ipc-main.ts | 43 +++++ apps/desktop/src/preload/bridge-contract.d.ts | 11 ++ apps/desktop/src/preload/preload.ts | 8 + apps/desktop/src/renderer/app-shell.tsx | 22 +++ apps/desktop/src/renderer/goal-dialog.tsx | 149 ++++++++++++++++++ .../src/renderer/locales/shell-copy.ts | 56 +++++++ apps/desktop/stories/app-shell.stories.tsx | 24 +++ docs/astryx-surface-file-inventory.md | 3 +- docs/astryx-surface-file-inventory.paths | 1 + packages/core/src/goal.ts | 12 ++ .../src/__tests__/goal-coordinator.test.ts | 115 ++++++++++++++ .../src/__tests__/goal-protocol.test.ts | 77 +++++++++ packages/runtime-host/src/protocol/goal.ts | 82 ++++++++++ .../runtime-host/src/protocol/operations.ts | 1 + .../src/server/goal-coordinator.ts | 61 ++++++- packages/runtime/src/goal-state.ts | 3 + packages/runtime/src/goal-tools.ts | 9 +- packages/ui/src/composer.tsx | 42 ++++- packages/ui/src/conversation-copy.ts | 6 + 22 files changed, 824 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/renderer/goal-dialog.tsx 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..225a3ef547 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,50 @@ import { type DomainClient = RuntimeHostSessionDomainsIpcDeps['client']; +test('goal:arm takes the Session from the scoped channel, not the renderer frame', 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', { + // A renderer-side Session id in the frame must not redirect the operation. + sessionId: 'session-somewhere-else', + 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')); +}); + test('adapts Host Goal, Task, Deep Research, and Resource projections', async () => { const controls: unknown[] = []; const client = domainClient({ @@ -720,6 +764,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..46b57cab5c 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1183,6 +1183,21 @@ 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: { + sessionId: string; + condition: string; + maxIterations: number | null; + tokenBudget: number | null; + }): 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..e06f0d7735 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 @@ -27,6 +27,7 @@ import { type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient & Pick< DesktopRuntimeHostClient, + | 'armGoal' | 'clearGoal' | 'controlGoalWithRetry' | 'controlPlan' @@ -100,6 +101,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 +310,41 @@ 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 (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..a5d4cead47 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -724,6 +724,17 @@ 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(input: { + sessionId: string; + condition: string; + maxIterations?: number | null; + tokenBudget?: number | null; + }): 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..1d24411c4b 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1910,6 +1910,14 @@ const makaBridge = { get(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('goal:get', sessionId); }, + arm(input: { + sessionId: string; + condition: string; + maxIterations?: number | null; + tokenBudget?: number | null; + }): Promise { + return invokeProjectedSessionRuntimeHost('goal:arm', input.sessionId, input); + }, 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)} + /> (null); + const [tokenBudget, setTokenBudget] = useState(null); + 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(''); + setMaxIterations(null); + setTokenBudget(null); + setError(undefined); + setArming(false); + }, [props.sessionId]); + + const sessionId = props.sessionId; + const canSubmit = condition.trim().length > 0 && !arming; + + async function arm(): Promise { + if (!sessionId || !canSubmit) return; + setArming(true); + setError(undefined); + try { + await window.maka.goal.arm({ + sessionId, + condition: condition.trim(), + maxIterations, + tokenBudget, + }); + 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} +