Skip to content

Commit 57e08d8

Browse files
authored
feat(goal): let the user arm a Goal from the composer (#3199)
A Goal could only be armed by the model, from inside a Turn, with the GoalSet tool. There was no Host operation for it and no control anywhere in the product: the user could stop a Goal but never start one. `goal.arm` joins `goal.query` and `goal.control` as a real Host operation. `HostGoalCoordinator#arm` creates the Goal through the same `GoalManager` the tool uses, under the same Session admission gate, with the same durable record, and refuses a Session that already has an unfinished one. 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 rather than two copies of it. Remote owners are granted the operation at the same tier as `goal.control`: they already send Turns, and the model arms its own Goal inside one, so refusing would remove only the explicit path the user can see and stop. The IPC handler takes the Session from the scoped channel rather than the renderer's frame, and normalizes the budgets without clamping them. The + menu gains one row that opens a dialog for the condition and the two budgets, and explains itself when a Goal is already running or a Turn is in flight. Arming starts nothing, which the Goal itself has to remember. A Goal the model sets drives from the moment it exists; an armed one waits for a Turn to take hold of it, and `active` covers both. `armedAt` records that wait, and the two events that end it clear it — a carried Turn settling into a continuation, and the user resuming. `isDrivingGoal` reads that one field, so a restart puts back only a drive the Goal already has, while Resume delivers the immediate continuation its control promises. Epoch 28 is retired: `goal.arm` is a new wire operation, and an older Host decodes it as unknown and tears the connection down instead of refusing the pair up front. Reviewed-by: M4n5ter Reviewed-by: hqhq1025 Generated-by: Claude Code
1 parent db0432e commit 57e08d8

29 files changed

Lines changed: 1487 additions & 57 deletions
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { test, expect, COMPOSER_INPUT } from './fixtures';
2+
3+
/**
4+
* Arming a Goal starts unattended token spending, and the two budgets in this
5+
* dialog are what stops it. A budget the form shows but does not send is
6+
* therefore the one failure this dialog must not have — most sharply when the
7+
* value is dropped rather than altered, because an absent token budget is not
8+
* a smaller ceiling but no ceiling at all.
9+
*
10+
* The assertions read the Goal back from the Host rather than watching the
11+
* bridge call, so they answer what was actually armed.
12+
*/
13+
test('an unsendable budget blocks Start instead of arming a different one', async ({
14+
window: page,
15+
}) => {
16+
// The + menu only offers a Goal for a Session that exists, so seed one and
17+
// let its Turn settle first — a live Turn disables the entry too.
18+
const composer = page.locator(COMPOSER_INPUT);
19+
await composer.fill('seed session');
20+
await composer.press('Enter');
21+
await expect(page.getByRole('log').getByText(/Fake backend received: seed session/)).toBeVisible();
22+
await expect(page.getByRole('button', { name: '重新生成' })).toHaveCount(1, { timeout: 20_000 });
23+
24+
const sessionId = await page.evaluate(async () => (await window.maka.sessions.list())[0]?.id);
25+
expect(sessionId).toBeTruthy();
26+
const armedGoal = () =>
27+
page.evaluate(async (id: string) => await window.maka.goal.get(id), sessionId as string);
28+
29+
await page.getByRole('button', { name: '添加上下文' }).click();
30+
await page.getByRole('menuitem', { name: '设定 Goal…' }).click();
31+
32+
const dialog = page.getByRole('dialog');
33+
await dialog.getByLabel(//).fill('所有测试通过');
34+
const start = dialog.getByRole('button', { name: '开始' });
35+
await expect(start).toBeEnabled();
36+
37+
// Below the Host's own minimum. The field this replaced kept such text to
38+
// itself and left the sent budget null, so Start stayed enabled and armed no
39+
// ceiling at all.
40+
await dialog.getByLabel(/Token /).fill('500');
41+
await expect(start).toBeDisabled();
42+
await expect(dialog.getByText(/ 1000 /)).toBeVisible();
43+
44+
await dialog.getByLabel(/Token /).fill('5000');
45+
await expect(start).toBeEnabled();
46+
47+
// Above the Host's ceiling on turns; the same rule from the other side.
48+
await dialog.getByLabel(//).fill('250');
49+
await expect(start).toBeDisabled();
50+
await expect(await armedGoal()).toBeNull();
51+
52+
await dialog.getByLabel(//).fill('25');
53+
await expect(start).toBeEnabled();
54+
await start.click();
55+
56+
await expect
57+
.poll(async () => {
58+
const goal = await armedGoal();
59+
return goal && {
60+
condition: goal.condition,
61+
maxIterations: goal.maxIterations,
62+
tokenBudget: goal.tokenBudget,
63+
};
64+
})
65+
.toEqual({ condition: '所有测试通过', maxIterations: 25, tokenBudget: 5000 });
66+
});

apps/desktop/src/main/__tests__/runtime-host-client-operations.test.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,52 @@ test('controlGoalWithRetry rethrows a status refusal instead of retrying it away
754754
);
755755
});
756756

757+
test('arms a Goal in one request and reports a conflicting Goal instead of retrying', async () => {
758+
const armed = goalProjection(0);
759+
const { client, requests } = clientWithResponses([
760+
{ sessionId: 'session-1', goal: armed },
761+
]);
762+
763+
const result = await client.armGoal({
764+
sessionId: 'session-1',
765+
condition: 'All tests pass',
766+
maxIterations: 20,
767+
tokenBudget: null,
768+
});
769+
770+
assert.deepEqual(result, { sessionId: 'session-1', goal: armed });
771+
assert.deepEqual(
772+
requests.filter(({ operation }) => operation === 'goal.arm').map(({ input }) => input),
773+
[
774+
{
775+
sessionId: 'session-1',
776+
condition: 'All tests pass',
777+
maxIterations: 20,
778+
tokenBudget: null,
779+
},
780+
],
781+
);
782+
783+
// Arming names no revision, so a conflict is an answer for the user — the
784+
// Session already has a Goal — not a stale read to refresh and re-send.
785+
const conflicted = clientWithResponses([
786+
new RuntimeHostOperationError('goal.arm', 'operation_conflict', 'Goal already set'),
787+
]);
788+
await assert.rejects(
789+
conflicted.client.armGoal({
790+
sessionId: 'session-1',
791+
condition: 'All tests pass',
792+
maxIterations: null,
793+
tokenBudget: null,
794+
}),
795+
/Goal already set/,
796+
);
797+
assert.equal(
798+
conflicted.requests.filter(({ operation }) => operation === 'goal.arm').length,
799+
1,
800+
);
801+
});
802+
757803
test('rejects an invalid sidecar continuation without misclassifying it as revision churn', async () => {
758804
const revision = catalogRevision('7');
759805
const { client, requests } = clientWithResponses([

apps/desktop/src/main/__tests__/runtime-host-session-domains-ipc-main.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,65 @@ import {
1717

1818
type DomainClient = RuntimeHostSessionDomainsIpcDeps['client'];
1919

20+
test('goal:arm takes the Session from the scoped channel and refuses any other key', async () => {
21+
const armed: unknown[] = [];
22+
const client = domainClient({
23+
armGoal: async (input) => {
24+
armed.push(input);
25+
return { sessionId: input.sessionId, goal: goalProjection() };
26+
},
27+
});
28+
const ipc = ipcHarness();
29+
registerDomainsIpc({ client, emitModeChanged() {} }, ipc);
30+
31+
const goal = await ipc.invoke('goal:arm', 'session-1', {
32+
condition: 'Finish the adapter',
33+
maxIterations: 20,
34+
tokenBudget: 1_000,
35+
});
36+
assert.deepEqual(armed, [
37+
{
38+
sessionId: 'session-1',
39+
condition: 'Finish the adapter',
40+
maxIterations: 20,
41+
tokenBudget: 1_000,
42+
},
43+
]);
44+
assert.equal((goal as { id: string }).id, 'goal-1');
45+
46+
// Omitted budgets are "not chosen", which the Host reads as its defaults.
47+
await ipc.invoke('goal:arm', 'session-1', { condition: 'Finish the adapter' });
48+
assert.deepEqual(armed[1], {
49+
sessionId: 'session-1',
50+
condition: 'Finish the adapter',
51+
maxIterations: null,
52+
tokenBudget: null,
53+
});
54+
55+
await assert.rejects(ipc.invoke('goal:arm', 'session-1', { condition: ' ' }));
56+
await assert.rejects(
57+
ipc.invoke('goal:arm', 'session-1', { condition: 'Finish', maxIterations: 0 }),
58+
);
59+
await assert.rejects(ipc.invoke('goal:arm', 'session-1', 'not-an-object'));
60+
61+
// Any key this frame does not carry is a caller mistake. Dropping it would
62+
// send the Host a frame the caller did not write, so it is refused instead.
63+
await assert.rejects(
64+
ipc.invoke('goal:arm', 'session-1', { condition: 'Finish', blockCap: 5 }),
65+
/Invalid Goal arm input/,
66+
);
67+
// The Session is one of those keys: it comes from the scoped channel, so a
68+
// renderer-side Session id cannot redirect the operation even by matching.
69+
await assert.rejects(
70+
ipc.invoke('goal:arm', 'session-1', {
71+
sessionId: 'session-somewhere-else',
72+
condition: 'Finish',
73+
}),
74+
/Invalid Goal arm input/,
75+
);
76+
assert.equal(armed.length, 2);
77+
});
78+
2079
test('adapts Host Goal, Task, Deep Research, and Resource projections', async () => {
2180
const controls: unknown[] = [];
2281
const client = domainClient({
@@ -720,6 +779,7 @@ function domainClient(overrides: Partial<DomainClient>): DomainClient {
720779
throw new Error('Unexpected domain operation');
721780
};
722781
return {
782+
armGoal: unavailable,
723783
clearGoal: unavailable,
724784
acquireRuntimeResourceController: unavailable,
725785
controlPlan: unavailable,

apps/desktop/src/main/runtime-host-client.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1183,6 +1183,16 @@ export class DesktopRuntimeHostClient {
11831183
return this.request("goal.query", { sessionId });
11841184
}
11851185

1186+
/**
1187+
* Arm a Goal the user asked for. No optimistic retry loop like `clearGoal`:
1188+
* arming names no revision, so there is no stale one to refresh — a Session
1189+
* that already has an unfinished Goal fails with `operation_conflict`, and
1190+
* that is an answer for the user, not a race to re-run.
1191+
*/
1192+
armGoal(input: OperationInput<"goal.arm">): Promise<OperationOutput<"goal.arm">> {
1193+
return this.request("goal.arm", input);
1194+
}
1195+
11861196
controlGoal(
11871197
goal: Pick<GoalProjection, "sessionId" | "goalId" | "revision">,
11881198
action: GoalControlAction,

apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
import type { AgentGraphEpochDirectory } from '@maka/runtime-host/client';
1515
import type { DesktopRuntimeHostClient } from './runtime-host-client.js';
1616
import type { RuntimeHostSessionObserver } from './runtime-host-session-observer.js';
17+
import { GOAL_ARM_REQUEST_KEYS } from '../shared/goal-arm.js';
1718
import { projectHostedDeepResearch } from './deep-research-desktop-projection.js';
1819
import {
1920
handleReconnectableRead,
@@ -27,6 +28,7 @@ import {
2728
type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient &
2829
Pick<
2930
DesktopRuntimeHostClient,
31+
| 'armGoal'
3032
| 'clearGoal'
3133
| 'controlGoalWithRetry'
3234
| 'controlPlan'
@@ -100,6 +102,13 @@ export function registerRuntimeHostSessionDomainsIpc(
100102
ipcMain.handle('goal:resume', async (_event, sessionId: unknown) => {
101103
await deps.client.controlGoalWithRetry(requiredId(sessionId, 'Session'), 'resume');
102104
});
105+
ipcMain.handle('goal:arm', async (_event, sessionId: unknown, input: unknown) => {
106+
const result = await deps.client.armGoal({
107+
sessionId: requiredId(sessionId, 'Session'),
108+
...requireGoalArmBudgets(input),
109+
});
110+
return toDesktopGoal(result.goal);
111+
});
103112

104113
handleReconnectableRead(ipcMain, 'plan-mode:getState', (_event, sessionId: unknown) =>
105114
deps.client.getPlanState(requiredId(sessionId, 'Session')),
@@ -302,6 +311,44 @@ async function refreshRuntimeResources(
302311
}
303312
}
304313

314+
/**
315+
* The renderer sends what the user typed; the Host owns every bound. This only
316+
* gets the frame into the shape the protocol decodes — numbers stay numbers,
317+
* "not chosen" stays null — so an out-of-range budget is refused once, by the
318+
* Host, instead of being clamped here into something the user did not ask for.
319+
* The Session comes from the scoped IPC argument, not from this frame, so a
320+
* renderer-side Session id can never redirect the operation.
321+
*/
322+
function requireGoalArmBudgets(value: unknown): {
323+
condition: string;
324+
maxIterations: number | null;
325+
tokenBudget: number | null;
326+
} {
327+
if (typeof value !== 'object' || value === null) {
328+
throw new TypeError('Goal arm input must be an object');
329+
}
330+
const record = value as Record<string, unknown>;
331+
if (Object.keys(record).some((key) => !GOAL_ARM_REQUEST_KEYS.includes(key as never))) {
332+
throw new TypeError('Invalid Goal arm input');
333+
}
334+
if (typeof record.condition !== 'string' || record.condition.trim().length === 0) {
335+
throw new TypeError('Goal condition is required');
336+
}
337+
return {
338+
condition: record.condition,
339+
maxIterations: optionalCount(record.maxIterations, 'Goal maxIterations'),
340+
tokenBudget: optionalCount(record.tokenBudget, 'Goal tokenBudget'),
341+
};
342+
}
343+
344+
function optionalCount(value: unknown, label: string): number | null {
345+
if (value === null || value === undefined) return null;
346+
if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {
347+
throw new TypeError(`${label} must be a positive integer`);
348+
}
349+
return value;
350+
}
351+
305352
function toDesktopGoal(goal: GoalProjection): GoalState {
306353
return {
307354
id: goal.goalId,

apps/desktop/src/preload/bridge-contract.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,15 @@ export interface MakaBridge {
724724
goal: {
725725
/** The session's current goal (null when none is set). */
726726
get(sessionId: string): Promise<import('@maka/runtime/goal-state').GoalState | null>;
727+
/**
728+
* Arm a goal for this session. It drives the session from the next turn
729+
* on; arming alone starts nothing. Rejects when the session already has an
730+
* unfinished goal.
731+
*/
732+
arm(
733+
sessionId: string,
734+
goal: import('../shared/goal-arm').GoalArmRequest,
735+
): Promise<import('@maka/runtime/goal-state').GoalState>;
727736
/** Clear the active goal, stopping autonomous continuation. */
728737
clear(sessionId: string): Promise<void>;
729738
/** Pause the active goal without spending a model turn. */

apps/desktop/src/preload/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,7 @@ import {
170170
requireDesktopTargetScope,
171171
type DesktopTargetScope,
172172
} from '../shared/runtime-host-identity.js';
173+
import type { GoalArmRequest } from '../shared/goal-arm.js';
173174
import {
174175
projectDesktopAttachmentRefs,
175176
projectDesktopDailyReviewSummary,
@@ -1910,6 +1911,9 @@ const makaBridge = {
19101911
get(sessionId: string): Promise<GoalState | null> {
19111912
return invokeProjectedSessionRuntimeHost('goal:get', sessionId);
19121913
},
1914+
arm(sessionId: string, goal: GoalArmRequest): Promise<GoalState> {
1915+
return invokeProjectedSessionRuntimeHost('goal:arm', sessionId, goal);
1916+
},
19131917
clear(sessionId: string): Promise<void> {
19141918
return invokeSessionRuntimeHost('goal:clear', sessionId);
19151919
},

apps/desktop/src/renderer/app-shell.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function rebaseWorkspaceFileReferences(
227227

228228
import { useSettingsModal } from './use-settings-modal';
229229
import { RemoteProjectDirectoryDialog } from './remote-project-directory-dialog';
230+
import { GoalDialog } from './goal-dialog.js';
230231
import { useSystemUiLocale } from './use-system-ui-locale';
231232
import {
232233
isSessionWorkspaceUnavailableError,
@@ -776,6 +777,12 @@ function AppShellContent({
776777
},
777778
[],
778779
);
780+
/**
781+
* The Session the Goal dialog is arming, or `undefined` when it is closed.
782+
* Keyed by Session rather than a boolean so switching Sessions while the
783+
* dialog is open can never arm the wrong one.
784+
*/
785+
const [goalDialogSessionId, setGoalDialogSessionId] = useState<string>();
779786
// Set of session ids whose backend / connection is no longer usable —
780787
// drives the sidebar "已过期" pill (PR108g, paired with the PR108e chat
781788
// header banner). Derivation is pure (see `stale-sessions.ts`) so the
@@ -3271,6 +3278,17 @@ function AppShellContent({
32713278
onOrchestrationModeChange={(mode) => {
32723279
void setOrchestrationMode(mode);
32733280
}}
3281+
onSetGoal={
3282+
activeId && activeBoundarySurface.localInteractionAvailable
3283+
? () => setGoalDialogSessionId(activeId)
3284+
: undefined
3285+
}
3286+
goalActive={activeGoal !== null}
3287+
goalDisabledReason={
3288+
activeStreamingLive || (activeId && turnActive)
3289+
? shellCopy.goalTurnActive
3290+
: undefined
3291+
}
32743292
/>
32753293
</>
32763294
}
@@ -3586,6 +3604,10 @@ function AppShellContent({
35863604
}}
35873605
/>
35883606

3607+
<GoalDialog
3608+
{...(goalDialogSessionId ? { sessionId: goalDialogSessionId } : {})}
3609+
onClose={() => setGoalDialogSessionId(undefined)}
3610+
/>
35893611
<RuntimeHostSshTerminalDialog />
35903612

35913613
<RemoteProjectDirectoryDialog

0 commit comments

Comments
 (0)