Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions apps/desktop/e2e/goal-dialog-budget.spec.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -720,6 +779,7 @@ function domainClient(overrides: Partial<DomainClient>): DomainClient {
throw new Error('Unexpected domain operation');
};
return {
armGoal: unavailable,
clearGoal: unavailable,
acquireRuntimeResourceController: unavailable,
controlPlan: unavailable,
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/runtime-host-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<OperationOutput<"goal.arm">> {
return this.request("goal.arm", input);
Comment thread
M4n5ter marked this conversation as resolved.
}

controlGoal(
goal: Pick<GoalProjection, "sessionId" | "goalId" | "revision">,
action: GoalControlAction,
Expand Down
47 changes: 47 additions & 0 deletions apps/desktop/src/main/runtime-host-session-domains-ipc-main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -27,6 +28,7 @@ import {
type RuntimeHostSessionDomainClient = RuntimeHostShellRunsClient &
Pick<
DesktopRuntimeHostClient,
| 'armGoal'
| 'clearGoal'
| 'controlGoalWithRetry'
| 'controlPlan'
Expand Down Expand Up @@ -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')),
Expand Down Expand Up @@ -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<string, unknown>;
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,
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/preload/bridge-contract.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,15 @@ export interface MakaBridge {
goal: {
/** The session's current goal (null when none is set). */
get(sessionId: string): Promise<import('@maka/runtime/goal-state').GoalState | null>;
/**
* 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<import('@maka/runtime/goal-state').GoalState>;
/** Clear the active goal, stopping autonomous continuation. */
clear(sessionId: string): Promise<void>;
/** Pause the active goal without spending a model turn. */
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/preload/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1910,6 +1911,9 @@ const makaBridge = {
get(sessionId: string): Promise<GoalState | null> {
return invokeProjectedSessionRuntimeHost('goal:get', sessionId);
},
arm(sessionId: string, goal: GoalArmRequest): Promise<GoalState> {
return invokeProjectedSessionRuntimeHost('goal:arm', sessionId, goal);
},
clear(sessionId: string): Promise<void> {
return invokeSessionRuntimeHost('goal:clear', sessionId);
},
Expand Down
22 changes: 22 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string>();
// 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
Expand Down Expand Up @@ -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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
</>
}
Expand Down Expand Up @@ -3586,6 +3604,10 @@ function AppShellContent({
}}
/>

<GoalDialog
{...(goalDialogSessionId ? { sessionId: goalDialogSessionId } : {})}
onClose={() => setGoalDialogSessionId(undefined)}
/>
<RuntimeHostSshTerminalDialog />

<RemoteProjectDirectoryDialog
Expand Down
Loading