diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d6af22878..574dd190bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ ### Changed +- Distinguished Goals that are armed and waiting for their first Turn from + Goals that are already running across Desktop and CLI/TUI surfaces. Goal + conditions shown in client status, tooltip, lineage, and reconciliation + displays are now redacted. The Runtime Host compatibility epoch moves to 111. - Made typed `request()` the sole direct Runtime Host operation API; removed the 17 forwarding aliases from direct and reconnecting connections while preserving status validation, subscriptions, capabilities, listeners, lifecycle, and close behavior. diff --git a/apps/desktop/src/main/__tests__/goal-controller.test.ts b/apps/desktop/src/main/__tests__/goal-controller.test.ts index f929874872..5b19c02ec8 100644 --- a/apps/desktop/src/main/__tests__/goal-controller.test.ts +++ b/apps/desktop/src/main/__tests__/goal-controller.test.ts @@ -237,6 +237,29 @@ describe('useGoalController', () => { assert.equal(pauseCalls, 2); }); + it('shows an armed marker only while the first Turn is unbound', async () => { + const { root } = installReactRenderer(); + const defaults = createFakeGoalServices(); + const services = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => ({ ...goal('a'), armedAt: 150 }), + }, + }); + + await act(async () => renderController(root, services, input('a'))); + assert.equal(controller().selectors.indicator?.isArmed, true); + + const boundServices = createFakeGoalServices({ + goal: { + ...defaults.goal, + get: async () => ({ ...goal('a'), armedAt: 150, boundTurnId: 'turn-1' }), + }, + }); + await act(async () => renderController(root, boundServices, input('a'))); + assert.equal(controller().selectors.indicator?.isArmed, false); + }); + it('routes resume and clear controls for paused Goals', async () => { const { root } = installReactRenderer(); const calls: string[] = []; diff --git a/apps/desktop/src/main/__tests__/goal-dialog.test.ts b/apps/desktop/src/main/__tests__/goal-dialog.test.ts index ee233698c2..4a112c3951 100644 --- a/apps/desktop/src/main/__tests__/goal-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.ts @@ -121,6 +121,21 @@ test('closes only for armed and locks reconciled state until reopen', async () = assert.equal(harness.closed, 1); }); +test('redacts secrets in a reconciled Goal condition', async () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const harness = installGoalDialog(async () => ({ + kind: 'reconciled', + currentGoal: { ...goalState(), condition: `Use Authorization: Bearer ${secret}` }, + matchesRequestedState: true, + })); + await harness.render('session-1'); + await setInputValue(harness.document, 'textarea', 'Finish session one'); + await clickButton(harness.document, 'Start'); + + assert.equal(harness.document.body.textContent.includes(secret), false); + assert.match(harness.document.body.textContent, /Authorization: Bearer /); +}); + test('keeps the Goal form editable after a deterministic rejection', async () => { const harness = installGoalDialog(async () => { throw new Error('Goal already exists'); 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 302d644243..2e2c1ee279 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 @@ -949,6 +949,8 @@ function goalProjection(revision: number) { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, }; } 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 0188653324..bce6095233 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 @@ -117,6 +117,7 @@ test('goal:arm reconciles a lost dispatched response without dispatching again', tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }, matchesRequestedState: true, }, @@ -311,6 +312,7 @@ test('goal:arm reconciliation reports different, missing, and unavailable author tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }, matchesRequestedState: false, }, @@ -490,6 +492,7 @@ test('adapts Host Goal, Task, Deep Research, and Resource projections', async () tokensAtStart: 0, tokensNow: 120, tokensBaselinePending: false, + armedAt: 7, }); await ipc.invoke('goal:clear', 'session-1'); await ipc.invoke('goal:pause', 'session-1'); @@ -1217,6 +1220,8 @@ function baseGoalProjection() { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: 7, + boundTurnId: null, }; } diff --git a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts index ea76a37f39..59b16f7fe8 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-session-observer.test.ts @@ -2858,6 +2858,8 @@ test("publishes Host sidecar and graph invalidations without inventing Session s lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, }, }), }); @@ -2966,6 +2968,8 @@ function activeGoal() { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, }; } 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 a68664aac7..98912aaa9f 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 @@ -24,7 +24,7 @@ import type { AgentGraphClientSnapshotOptions, AgentGraphOperatorInspection, } from '@maka/runtime/stream-graph-read-model'; -import { DEFAULT_MAX_ITERATIONS, type GoalState } from '@maka/runtime/goal-state'; +import { DEFAULT_MAX_ITERATIONS } from '@maka/runtime/goal-state'; import type { ShellRunPtyDataEvent } from '@maka/runtime/shell-run-contract'; import type { GoalProjection, @@ -37,6 +37,7 @@ import { GOAL_ARM_REQUEST_KEYS, type GoalArmOutcome, } from '../shared/goal-arm.js'; +import type { DesktopGoalState } from '../shared/goal-arm.js'; import { projectHostedDeepResearch } from './deep-research-desktop-projection.js'; import { handleReconciledControl, @@ -451,7 +452,7 @@ function optionalCount(value: unknown, label: string): number | null { return value; } -function toDesktopGoal(goal: GoalProjection): GoalState { +function toDesktopGoal(goal: GoalProjection): DesktopGoalState { return { id: goal.goalId, revision: goal.revision, @@ -470,6 +471,8 @@ function toDesktopGoal(goal: GoalProjection): GoalState { ...(goal.lastReason === null ? {} : { lastReason: goal.lastReason }), ...(goal.achievedAt === null ? {} : { achievedAt: goal.achievedAt }), ...(goal.pausedAt === null ? {} : { pausedAt: goal.pausedAt }), + ...(goal.armedAt === null ? {} : { armedAt: goal.armedAt }), + ...(goal.boundTurnId === null ? {} : { boundTurnId: goal.boundTurnId }), }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 117a5f6827..36afe015fa 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1372,7 +1372,7 @@ export interface MakaBridge { }; goal: { /** The session's current goal (null when none is set). */ - get(sessionId: string): Promise; + 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 diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 83c8b6645f..2c106e8724 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -205,7 +205,7 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; -import type { GoalState } from '@maka/runtime/goal-state'; +import type { DesktopGoalState } from '../shared/goal-arm.js'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; import type { ConfigCategory } from '@maka/storage/config-transfer'; import { @@ -2752,7 +2752,7 @@ const makaBridge = { }, }, goal: { - get(sessionId: string): Promise { + get(sessionId: string): Promise { return invokeProjectedSessionRuntimeHost('goal:get', sessionId); }, arm(sessionId: string, goal: GoalArmRequest): Promise { diff --git a/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts index 09ad21e35a..cac84411b2 100644 --- a/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts +++ b/apps/desktop/src/renderer/features/goals/controller/use-goal-controller.ts @@ -25,6 +25,7 @@ import { useState, type ComponentProps, } from 'react'; +import { isGoalArmedAwaitingFirstTurn } from '@maka/core/goal'; import { useUiLocale, type ChatView } from '@maka/ui'; import { getShellCopy, @@ -144,6 +145,7 @@ export function useGoalController( iterations: activeGoal.iterations, maxIterations: activeGoal.maxIterations, setAt: activeGoal.setAt, + isArmed: isGoalArmedAwaitingFirstTurn(activeGoal), tokensSpent: activeGoal.tokensNow, ...(activeGoal.tokenBudget !== undefined ? { tokenBudget: activeGoal.tokenBudget } diff --git a/apps/desktop/src/renderer/features/goals/model/live-goal.ts b/apps/desktop/src/renderer/features/goals/model/live-goal.ts index 2acac13506..8a8d79c081 100644 --- a/apps/desktop/src/renderer/features/goals/model/live-goal.ts +++ b/apps/desktop/src/renderer/features/goals/model/live-goal.ts @@ -17,13 +17,14 @@ * under the License. */ -import type { GoalState, GoalStatus } from '@maka/core/goal'; +import type { GoalStatus } from '@maka/core/goal'; +import type { DesktopGoalState } from '../../../../shared/goal-arm.js'; type LiveGoalStatus = Extract; export type LiveGoalState = - | (GoalState & { readonly status: LiveGoalStatus }) - | (GoalState & { readonly status: 'paused'; readonly pausedAt: number }); + | (DesktopGoalState & { readonly status: LiveGoalStatus }) + | (DesktopGoalState & { readonly status: 'paused'; readonly pausedAt: number }); const LIVE_GOAL_STATUSES: ReadonlySet = new Set([ 'active', @@ -31,7 +32,7 @@ const LIVE_GOAL_STATUSES: ReadonlySet = new Set([ 'paused', ]); -export function isLiveGoal(goal: GoalState): goal is LiveGoalState { +export function isLiveGoal(goal: DesktopGoalState): goal is LiveGoalState { return ( LIVE_GOAL_STATUSES.has(goal.status) && (goal.status !== 'paused' || diff --git a/apps/desktop/src/renderer/features/goals/ports.ts b/apps/desktop/src/renderer/features/goals/ports.ts index 486ea6d3f6..feaa17bfe6 100644 --- a/apps/desktop/src/renderer/features/goals/ports.ts +++ b/apps/desktop/src/renderer/features/goals/ports.ts @@ -17,8 +17,7 @@ * under the License. */ -import type { GoalState } from '@maka/core/goal'; -import type { GoalArmOutcome } from '../../../shared/goal-arm.js'; +import type { DesktopGoalState, GoalArmOutcome } from '../../../shared/goal-arm.js'; export type { GoalArmOutcome } from '../../../shared/goal-arm.js'; @@ -32,7 +31,7 @@ export interface GoalArmInput { /** The minimum environment capability needed by the Goals feature. */ export interface GoalService { - get(sessionId: string): Promise; + get(sessionId: string): Promise; arm(sessionId: string, goal: GoalArmInput): Promise; clear(sessionId: string): Promise; pause(sessionId: string): Promise; diff --git a/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx index 36b5a56904..adb05670a3 100644 --- a/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx +++ b/apps/desktop/src/renderer/features/goals/ui/goal-dialog.tsx @@ -42,7 +42,7 @@ import { GOAL_MAX_ITERATIONS_LIMIT, GOAL_TOKEN_BUDGET_MINIMUM, } from '@maka/core/goal'; -import { useUiLocale } from '@maka/ui'; +import { redactSecrets, useUiLocale } from '@maka/ui'; import { getShellCopy, localizedShellErrorMessage, @@ -109,12 +109,12 @@ export function GoalDialog(props: GoalDialogProps) { switch (reconciliation.kind) { case 'matching_goal': return copy.reconciledMatching( - reconciliation.goal.condition, + redactSecrets(reconciliation.goal.condition), copy.statusLabels[reconciliation.goal.status], ); case 'different_goal': return copy.reconciledDifferent( - reconciliation.goal.condition, + redactSecrets(reconciliation.goal.condition), copy.statusLabels[reconciliation.goal.status], ); case 'no_goal': diff --git a/apps/desktop/src/shared/goal-arm.ts b/apps/desktop/src/shared/goal-arm.ts index cf5d5d24f4..e5cd1edb7d 100644 --- a/apps/desktop/src/shared/goal-arm.ts +++ b/apps/desktop/src/shared/goal-arm.ts @@ -19,6 +19,11 @@ import type { GoalState } from '@maka/runtime/goal-state'; +/** Desktop-only runtime detail; it is transient and never persisted with a Goal. */ +export type DesktopGoalState = GoalState & { + readonly boundTurnId?: string; +}; + /** * What the renderer sends to arm a Goal. * @@ -34,10 +39,10 @@ export interface GoalArmRequest { } export type GoalArmOutcome = - | { readonly kind: 'armed'; readonly goal: GoalState } + | { readonly kind: 'armed'; readonly goal: DesktopGoalState } | { readonly kind: 'reconciled'; - readonly currentGoal: GoalState | null; + readonly currentGoal: DesktopGoalState | null; readonly matchesRequestedState: boolean; } | { readonly kind: 'reconciliation_unavailable' }; diff --git a/apps/desktop/stories/app-shell.stories.tsx b/apps/desktop/stories/app-shell.stories.tsx index d82ab9389b..4f88fa0f0d 100644 --- a/apps/desktop/stories/app-shell.stories.tsx +++ b/apps/desktop/stories/app-shell.stories.tsx @@ -1269,6 +1269,7 @@ export const SessionContextLayer: Story = { goal={{ condition: '把 Session Context Layer 收敛到可 review 状态', status: 'active', + isArmed: false, iterations: 4, maxIterations: 12, setAt: Date.now() - 12 * 60_000, @@ -1287,6 +1288,7 @@ export const SessionContextLayerWaiting: Story = { goal={{ condition: '等待 CI 状态变化后继续处理 review', status: 'waiting', + isArmed: false, iterations: 4, maxIterations: 12, setAt: Date.now() - 12 * 60_000, @@ -1307,6 +1309,7 @@ export const SessionContextLayerPaused: Story = { goal={{ condition: '把 Session Context Layer 收敛到可 review 状态', status: 'paused', + isArmed: false, iterations: 4, maxIterations: 12, setAt: pausedAt - 8 * 60_000, diff --git a/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 0dcf8484bb..64203a6b45 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -51,6 +51,8 @@ function goal(overrides: Partial = {}): GoalProjection { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, ...overrides, }; } @@ -99,6 +101,25 @@ describe('pi-goal display helpers', () => { ); }); + test('armed Goals remain set until their first bound Turn, without a running notice or elapsed time', () => { + const armed = goal({ armedAt: 1_000 }); + assert.equal(goalStatusLineText(armed, 61_000), 'goal set 3/50'); + assert.deepEqual(goalSummaryLines(armed, 61_000).slice(0, 2), [ + 'Goal: Ship the feature', + 'Status: set · 3/50 iterations', + ]); + assert.equal( + goalAttachedNoticeText(armed), + 'Autonomous goal is set (3/50): Ship the feature — it takes hold on the next Turn.', + ); + }); + + test('a bound first Turn makes the same Goal running again', () => { + const running = goal({ armedAt: 1_000, boundTurnId: 'turn-1' }); + assert.equal(goalStatusLineText(running, 61_000), 'goal 3/50 1m'); + assert.match(goalAttachedNoticeText(running), /Autonomous goal is running/); + }); + test('summary lines include budget only when set and the evaluator note only when present', () => { const plain = goalSummaryLines(goal(), 61_000); assert.equal(plain.length, 2); @@ -184,4 +205,14 @@ describe('pi-goal display helpers', () => { const long = goalAttachedNoticeText(goal({ condition: 'x'.repeat(200) })); assert.ok(long.includes('…') && long.length <= 210); }); + + test('redacts secrets from condition text in CLI goal displays', () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const current = goal({ condition: `Use Authorization: Bearer ${secret}` }); + + for (const text of [goalAttachedNoticeText(current), goalSummaryLines(current, 61_000)[0]!]) { + assert.equal(text.includes(secret), false); + assert.match(text, //); + } + }); }); diff --git a/packages/cli/src/__tests__/pi-transcript.test.ts b/packages/cli/src/__tests__/pi-transcript.test.ts index 6a8e205ed5..376021fc6d 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -199,6 +199,8 @@ describe('Maka Pi TUI transcript', () => { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, } as const; const active = stripAnsi( renderMakaPiStatusLine({ ...meta(), goal: { ...base, status: 'active' as const } }, 120), @@ -416,6 +418,8 @@ describe('Maka Pi TUI transcript', () => { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, status: 'active' as const, }, }, @@ -456,6 +460,8 @@ describe('Maka Pi TUI transcript', () => { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, status: 'active' as const, }, }; diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 2c633b4642..2d3c8d011c 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -7679,6 +7679,8 @@ describe('Maka Pi TUI runner', () => { lastReason: 'tests still failing', achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, }; test('/goal prints the live goal summary and the status line carries the indicator', async () => { @@ -7808,7 +7810,7 @@ describe('Maka Pi TUI runner', () => { test('/goal pause|resume|clear control the loop and print confirmations', async () => { const terminal = new FakeTerminal(160, 24); const driver = new SlashCommandDriver(); - driver.goal = armedGoal; + driver.goal = { ...armedGoal, armedAt: Date.now() }; const run = runMakaPiTui({ title: 'Maka', driver, @@ -7819,10 +7821,11 @@ describe('Maka Pi TUI runner', () => { terminal, }); - // Attaching to a session whose goal is running announces the loop — - // recovery never resumes a token-burning loop silently. + // Attaching to a session whose goal is armed announces that it is set; + // the notice must not be suppressed just because its first Turn is not + // bound yet. await waitFor(() => - plainTerminalOutput(terminal.output()).includes('Autonomous goal is running (2/50)'), + plainTerminalOutput(terminal.output()).includes('Autonomous goal is set (2/50)'), ); terminal.input('/goal pause'); @@ -7986,6 +7989,35 @@ describe('Maka Pi TUI runner', () => { ]); }); + test('resuming into a session with an armed goal announces that it is set', async () => { + const terminal = new FakeTerminal(160, 24); + const driver = new SlashCommandDriver([fakeSessionSummary('session-2', '/repo')]); + driver.goal = null; + driver.goalsBySessionId.set('session-2', { ...armedGoal, armedAt: Date.now() }); + const run = runMakaPiTui({ + title: 'Maka', + driver, + cwd: '/repo', + model: 'claude-sonnet-4-5', + connectionSlug: 'claude-subscription', + permissionMode: 'ask', + terminal, + resumeSessionId: 'session-2', + }); + + await waitFor(() => + plainTerminalOutput(terminal.output()).includes('Autonomous goal is set (2/50)'), + ); + + exitMaka(terminal); + await Promise.race([ + run, + delay(CLOSE_BUDGET_MS).then(() => { + throw new Error('TUI did not close during test cleanup'); + }), + ]); + }); + test('/goal control pre-validates impossible transitions', async () => { const terminal = new FakeTerminal(); const driver = new SlashCommandDriver(); diff --git a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts index 230b1ec72e..57db0dc80f 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -3009,6 +3009,8 @@ function goalProjection(overrides: Partial = {}): GoalProjection lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, ...overrides, }; } diff --git a/packages/cli/src/pi-goal.ts b/packages/cli/src/pi-goal.ts index fa972d3f08..63f3a5e0d3 100644 --- a/packages/cli/src/pi-goal.ts +++ b/packages/cli/src/pi-goal.ts @@ -25,7 +25,8 @@ * Codex parallel: codex-rs/tui/src/goal_display.rs. */ -import type { GoalStatus } from '@maka/core/goal'; +import { isGoalArmedAwaitingFirstTurn, type GoalStatus } from '@maka/core/goal'; +import { redactSecrets } from '@maka/core/display-redaction'; import type { GoalProjection } from '@maka/runtime-host/protocol'; import { formatTokenCount } from './pi-transcript-format.js'; import { stripAnsi } from './tui-ansi.js'; @@ -103,11 +104,19 @@ export function formatGoalElapsed(elapsedMs: number): string { export function goalStatusLineText( goal: Pick< GoalProjection, - 'status' | 'iterations' | 'maxIterations' | 'setAt' | 'pausedAt' | 'achievedAt' + | 'status' + | 'iterations' + | 'maxIterations' + | 'setAt' + | 'pausedAt' + | 'achievedAt' + | 'armedAt' + | 'boundTurnId' >, now: number, ): string { const counter = `${goal.iterations}/${goal.maxIterations}`; + if (isArmedGoal(goal)) return `goal set ${counter}`; if (goal.status === 'active') { return `goal ${counter} ${formatGoalElapsed(goalElapsedMs(goal, now))}`; } @@ -116,7 +125,7 @@ export function goalStatusLineText( /** Conditions and evaluator notes may legally embed newlines; collapse whitespace so notices stay one line per field. */ function inlineGoalText(value: string): string { - return stripAnsi(value) + return redactSecrets(stripAnsi(value)) .replace(/[\u0000-\u001f\u007f-\u009f]/gu, ' ') .replace(/\s+/g, ' ') .trim(); @@ -139,20 +148,27 @@ export function goalPausedNoticeText( * auto-continuing after recovery — a token-burning loop never resumes silently. */ export function goalAttachedNoticeText( - goal: Pick, + goal: Pick< + GoalProjection, + 'condition' | 'iterations' | 'maxIterations' | 'status' | 'armedAt' | 'boundTurnId' + >, ): string { const condition = inlineGoalText(goal.condition); const short = condition.length > 120 ? `${condition.slice(0, 119)}…` : condition; + if (isArmedGoal(goal)) { + return `Autonomous goal is set (${goal.iterations}/${goal.maxIterations}): ${short} — it takes hold on the next Turn.`; + } return `Autonomous goal is running (${goal.iterations}/${goal.maxIterations}): ${short} — /goal shows details, /goal pause pauses it.`; } /** Full `/goal` summary. Terminal goals are as welcome here as live ones. */ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { - const status = `Status: ${goalStatusLabel(goal.status)} · ${goal.iterations}/${goal.maxIterations} iterations`; + const status = `Status: ${isArmedGoal(goal) ? 'set' : goalStatusLabel(goal.status)} · ${goal.iterations}/${goal.maxIterations} iterations`; // Terminal verdicts other than `achieved` carry no freeze timestamp, so a // wall-clock elapsed would keep growing for a loop that already ended. const elapsedMeaningful = - isLiveGoalStatus(goal.status) || (goal.status === 'achieved' && goal.achievedAt !== null); + (!isArmedGoal(goal) && isLiveGoalStatus(goal.status)) || + (goal.status === 'achieved' && goal.achievedAt !== null); const lines = [ // A cleared goal keeps its terminal record, so say "cleared" up front // instead of presenting the condition as if it were still armed. @@ -171,3 +187,7 @@ export function goalSummaryLines(goal: GoalProjection, now: number): string[] { if (goal.lastReason) lines.push(`Last evaluator note: ${inlineGoalText(goal.lastReason)}`); return lines; } + +function isArmedGoal(goal: Pick): boolean { + return isGoalArmedAwaitingFirstTurn(goal); +} diff --git a/packages/core/src/goal.ts b/packages/core/src/goal.ts index eb4460cb20..eb121791ba 100644 --- a/packages/core/src/goal.ts +++ b/packages/core/src/goal.ts @@ -61,23 +61,24 @@ export interface GoalState { readonly achievedAt?: number; readonly pausedAt?: number; /** - * When `goal.arm` created this Goal, and absent once the Goal drives itself. + * When `goal.arm` created this Goal, retained through its first bound Turn. * * A Goal the model sets drives from the moment it exists: the Turn that set * it is already bound to it and settles into its first continuation. Arming * happens outside every Turn and deliberately starts nothing, so an armed - * Goal waits — for a Turn to carry it into a continuation, or for the user - * to resume it — and this records that wait. Both events clear it, so its - * absence is the whole fact `isDrivingGoal` reads. Absence is also what - * every Goal written before arming existed carries, which is what those - * Goals mean. + * Goal waits for a Turn to carry it. The Host pairs this durable marker with + * the transient bound Turn identity: no identity means still waiting; an + * identity means that first Turn is running. Settlement, pause, clear, and + * terminal verdicts all clear the marker. Its absence is also what every + * Goal written before arming existed carries. */ readonly armedAt?: number; } /** - * Whether this Goal admits its own continuation Turns, which a restart or a - * resume has to put back. + * Whether an active or waiting Goal admits its own continuation Turns, which + * a restart or resume has to put back. Callers must still check the status: + * a paused Goal also has no armed marker, but it must not schedule work. * * `status` cannot answer this: `active` covers both a Goal between * continuations and an armed one still waiting for its first Turn. Neither @@ -89,6 +90,20 @@ export function isDrivingGoal(goal: Pick): boolean { return goal.armedAt === undefined; } +/** Whether an armed Goal is still waiting for its first bound Turn. */ +export function isGoalArmedAwaitingFirstTurn(goal: { + readonly status: GoalStatus; + readonly armedAt?: number | null; + readonly boundTurnId?: string | null; +}): boolean { + return ( + goal.status === 'active' && + goal.armedAt !== undefined && + goal.armedAt !== null && + (goal.boundTurnId === undefined || goal.boundTurnId === null) + ); +} + export interface GoalCheckpoint { readonly goalId: string; readonly revision: number; diff --git a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts index b69e4e0538..59f072f5d1 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -605,6 +605,8 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf if (!armed.ok) return; assert.equal(armed.result.goal.goalId, 'goal-armed'); assert.equal(armed.result.goal.status, 'active'); + assert.equal(armed.result.goal.armedAt, 10); + assert.equal(armed.result.goal.boundTurnId, null); assert.equal(armed.result.goal.maxIterations, 20); assert.equal(armed.result.goal.tokenBudget, 50_000); assert.deepEqual( @@ -615,6 +617,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf armed, 'every client reads the Goal the Host just armed', ); + await waitForAsync(async () => (await goalStore.read(session.id)) !== null); const second = await coordinator.handlers['goal.arm']( @@ -658,6 +661,7 @@ test('goal.arm creates one Goal per Session and refuses a second while it is unf await coordinator.close(); } finally { + await stores.sessionStore.close?.(); await goalStore.close(); await owner.close(); await rm(base, { recursive: true, force: true }); @@ -756,6 +760,7 @@ test('a Goal armed but never carried by a Turn does not start itself after a res const goal = restarted.readProjection(session.id); assert.equal(goal?.status, 'active', 'the armed Goal survives the restart untouched'); assert.equal(goal?.iterations, 0, 'and no Turn ran for it'); + assert.ok(goal?.armedAt !== null, 'the projection keeps its armed state'); await restarted.close(); } finally { await goalStore.close(); diff --git a/packages/runtime-host/src/__tests__/goal-projection.test.ts b/packages/runtime-host/src/__tests__/goal-projection.test.ts new file mode 100644 index 0000000000..4a1e975680 --- /dev/null +++ b/packages/runtime-host/src/__tests__/goal-projection.test.ts @@ -0,0 +1,47 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { projectGoalState } from '../server/goal-projection.js'; + +test('projects the identity of the Turn bound to an armed Goal', () => { + const projection = projectGoalState( + { + id: 'goal-1', + revision: 0, + sessionId: 'session-1', + condition: 'Ship the feature', + status: 'active', + setAt: 1, + iterations: 0, + maxIterations: 50, + consecutiveNoProgress: 0, + blockCap: 8, + tokensAtStart: 0, + tokensNow: 0, + tokensBaselinePending: true, + armedAt: 1, + }, + 'turn-after-arm', + ); + + assert.equal(projection.armedAt, 1); + assert.equal(projection.boundTurnId, 'turn-after-arm'); +}); diff --git a/packages/runtime-host/src/__tests__/goal-protocol.test.ts b/packages/runtime-host/src/__tests__/goal-protocol.test.ts index f922dad0ce..766d3a6aa9 100644 --- a/packages/runtime-host/src/__tests__/goal-protocol.test.ts +++ b/packages/runtime-host/src/__tests__/goal-protocol.test.ts @@ -45,6 +45,8 @@ const goal = { lastReason: 'Waiting for an exact resume', achievedAt: null, pausedAt: 2, + armedAt: null, + boundTurnId: null, }; test('Goal query and exact-revision control frames round-trip', () => { @@ -135,6 +137,10 @@ test('Goal projection is part of the exact Session continuity schema', () => { }); test('Goal projection rejects unknown fields and text beyond the shared UTF-8 boundary', () => { + const { armedAt: _armedAt, ...legacyGoal } = goal; + assert.throws(() => decodeGoalProjection(legacyGoal)); + const { boundTurnId: _boundTurnId, ...unboundGoal } = goal; + assert.throws(() => decodeGoalProjection(unboundGoal)); assert.throws(() => decodeGoalProjection({ ...goal, extra: true })); assert.throws(() => decodeGoalProjection({ ...goal, condition: '界'.repeat(501) })); assert.throws(() => decodeGoalProjection({ ...goal, lastReason: '界'.repeat(501) })); diff --git a/packages/runtime-host/src/__tests__/protocol.test.ts b/packages/runtime-host/src/__tests__/protocol.test.ts index 6f8210429e..f0f2a416b2 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -144,6 +144,13 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 95); }); + test('publishes a new compatibility epoch for the Goal armed-state projection', () => { + // GoalProjection has an exact key set, so the armedAt and boundTurnId + // additions must reject mixed Client-Host peers during the handshake. The + // current main epoch is 110, so this generation is deliberately 111. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 110); + }); + test('rejects the legacy connection update result in the current compatibility epoch', () => { assert.throws( () => diff --git a/packages/runtime-host/src/protocol/goal.ts b/packages/runtime-host/src/protocol/goal.ts index cab1ff1b3a..38e02a8677 100644 --- a/packages/runtime-host/src/protocol/goal.ts +++ b/packages/runtime-host/src/protocol/goal.ts @@ -65,6 +65,13 @@ export interface GoalProjection { readonly lastReason: string | null; readonly achievedAt: number | null; readonly pausedAt: number | null; + /** + * Epoch ms from arming through the first bound Turn. A null `boundTurnId` + * means the Goal is still waiting; a value means that Turn is running. + */ + readonly armedAt: number | null; + /** The currently running Turn that observed this armed Goal, if any. */ + readonly boundTurnId: string | null; } export interface GoalQueryInput { @@ -169,6 +176,8 @@ export function decodeGoalProjection(value: unknown): GoalProjection { 'lastReason', 'achievedAt', 'pausedAt', + 'armedAt', + 'boundTurnId', ]); const condition = requireUtf8String( record.condition, @@ -195,6 +204,9 @@ export function decodeGoalProjection(value: unknown): GoalProjection { lastReason, achievedAt: requireNullableCount(record.achievedAt, 'Goal achievedAt'), pausedAt: requireNullableCount(record.pausedAt, 'Goal pausedAt'), + armedAt: requireNullableCount(record.armedAt, 'Goal armedAt'), + boundTurnId: + record.boundTurnId === null ? null : requireEntityId(record.boundTurnId, 'Goal boundTurnId'), }; } diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b929c378be..2e7f9ecda6 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -100,7 +100,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 110 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 111 as const; +// 111: Goal projections carry `armedAt` and `boundTurnId`. The exact projection +// schema makes this a closed wire change, so mixed-version peers must fail +// before decoding it. Re-derived from current `main` at epoch 110. // 110: Runtime Host is the sole schema-migration authority for its State Root. // Epoch 109 Desktop builds could migrate the event-only AgentRun schema while // an older service Host still held the root, leaving that Host querying a diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 7104a83076..3076b2aaa9 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -232,11 +232,15 @@ export class HostGoalCoordinator { readProjection(sessionId: string): GoalProjection | null { const goal = this.manager.get(sessionId); - return goal ? projectGoalState(goal) : null; + return goal + ? projectGoalState(goal, this.continuation.observedGoalTurnId(sessionId, goal.id)) + : null; } beginObservedTurn(sessionId: string, turnId: string): GoalObservedTurnStart { - return this.continuation.beginObservedTurn(sessionId, turnId); + const registration = this.continuation.beginObservedTurn(sessionId, turnId); + if (registration.kind === 'registered') this.#onProjectionChanged(sessionId); + return registration; } begin(input: HostedExecutionObservation): HostedExecutionCompletionObserver | undefined { @@ -245,6 +249,7 @@ export class HostGoalCoordinator { } const registration = this.continuation.beginObservedTurn(input.sessionId, input.turnId); if (registration.kind !== 'registered') return undefined; + this.#onProjectionChanged(input.sessionId); return (completion) => registration.settle(goalOutcomeFromCompletion(completion)).catch((error) => { this.#persistenceFailure ??= error; diff --git a/packages/runtime-host/src/server/goal-projection.ts b/packages/runtime-host/src/server/goal-projection.ts index f695841643..7aadd030e4 100644 --- a/packages/runtime-host/src/server/goal-projection.ts +++ b/packages/runtime-host/src/server/goal-projection.ts @@ -24,7 +24,10 @@ import { } from '@maka/runtime/goal-state'; import { decodeGoalProjection, type GoalProjection } from '../protocol/index.js'; -export function projectGoalState(goal: GoalState): GoalProjection { +export function projectGoalState( + goal: GoalState, + boundTurnId: string | null = null, +): GoalProjection { return decodeGoalProjection({ goalId: goal.id, revision: goal.revision, @@ -41,6 +44,8 @@ export function projectGoalState(goal: GoalState): GoalProjection { lastReason: goal.lastReason ?? null, achievedAt: goal.achievedAt ?? null, pausedAt: goal.pausedAt ?? null, + armedAt: goal.armedAt ?? null, + boundTurnId, }); } @@ -62,5 +67,7 @@ export function worstCaseGoalProjection(sessionId: string): GoalProjection { lastReason: '界'.repeat(GOAL_REASON_TEXT_LIMIT.codeUnits), achievedAt: Number.MAX_SAFE_INTEGER, pausedAt: Number.MAX_SAFE_INTEGER, + armedAt: Number.MAX_SAFE_INTEGER, + boundTurnId: 't'.repeat(128), }; } diff --git a/packages/runtime/src/__tests__/goal-state.test.ts b/packages/runtime/src/__tests__/goal-state.test.ts index 6ea022b89f..382f1c8cdc 100644 --- a/packages/runtime/src/__tests__/goal-state.test.ts +++ b/packages/runtime/src/__tests__/goal-state.test.ts @@ -269,6 +269,36 @@ describe('GoalManager arming', () => { assert.ok(resumed); assert.equal(isDrivingGoal(resumed), true); }); + + test('leaving the armed phase clears its marker, including terminal verdicts and clear', () => { + const { mgr } = createManager(); + const armed = createGoal(mgr, 'x', { armed: true }); + assert.equal(mgr.clear(SESSION)?.armedAt, undefined); + + const pausable = createGoal(mgr, 'x', { armed: true }); + assert.equal(mgr.pause(SESSION, { checkpoint: goalCheckpoint(pausable) })?.armedAt, undefined); + mgr.clear(SESSION); + + const achieved = createGoal(mgr, 'x', { armed: true }); + assert.equal( + mgr.settleTurn(SESSION, { + checkpoint: goalCheckpoint(achieved), + verdict: 'achieved', + reason: 'done', + })?.armedAt, + undefined, + ); + + const impossible = createGoal(mgr, 'x', { armed: true }); + assert.equal( + mgr.settleTurn(SESSION, { + checkpoint: goalCheckpoint(impossible), + verdict: 'impossible', + reason: 'blocked', + })?.armedAt, + undefined, + ); + }); }); describe('GoalManager atomic turn settlement', () => { diff --git a/packages/runtime/src/goal-continuation.ts b/packages/runtime/src/goal-continuation.ts index 9abe61dfdc..369f4789fd 100644 --- a/packages/runtime/src/goal-continuation.ts +++ b/packages/runtime/src/goal-continuation.ts @@ -249,6 +249,21 @@ export class GoalContinuationCoordinator { }; } + /** The in-flight Turn that actually observed this armed Goal, if any. */ + observedGoalTurnId(sessionId: string, goalId: string): string | null { + const goal = this.deps.goalManager.get(sessionId); + const controlLease = this.deps.goalManager.getControlLease(sessionId); + if (!goal || goal.id !== goalId || goal.armedAt === undefined || !controlLease) return null; + const lane = this.lanes.get(sessionId); + if (!lane) return null; + for (const registration of lane.turns.values()) { + if (sameGoalControlLease(registration.controlLease, controlLease)) { + return registration.turnId; + } + } + return null; + } + /** * Why this turn may not arm a Goal, or that it may. * diff --git a/packages/runtime/src/goal-state.ts b/packages/runtime/src/goal-state.ts index 097f139b57..964bc5b530 100644 --- a/packages/runtime/src/goal-state.ts +++ b/packages/runtime/src/goal-state.ts @@ -328,9 +328,10 @@ export class GoalManager { status: 'achieved', lastReason: input.reason, achievedAt: this.deps.now(), + armedAt: undefined, }; } else if (input.verdict === 'impossible') { - patch = { status: 'impossible', lastReason: input.reason }; + patch = { status: 'impossible', lastReason: input.reason, armedAt: undefined }; } else { let tokensAtStart = current.tokensAtStart; let tokensNow = current.tokensNow; @@ -409,6 +410,7 @@ export class GoalManager { { status: 'paused', pausedAt: this.deps.now(), + armedAt: undefined, ...(options?.reason !== undefined ? { lastReason: options.reason } : {}), }, { renewControlLease: true }, @@ -440,7 +442,11 @@ export class GoalManager { clear(sessionId: string): GoalState | undefined { const record = this.goals.get(sessionId); if (!record || TERMINAL_GOAL_STATUSES.has(record.state.status)) return undefined; - return this.commit(record, { status: 'cleared' }, { renewControlLease: true }); + return this.commit( + record, + { status: 'cleared', armedAt: undefined }, + { renewControlLease: true }, + ); } remove(sessionId: string): boolean { diff --git a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx index 087b30a82a..b1c9bebd58 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -43,6 +43,7 @@ test('a running goal reads as running and offers pause, with elapsed and tokens' status: 'active', iterations: 3, maxIterations: 50, + isArmed: false, setAt: Date.now() - 12 * 60_000, tokensSpent: 12_000, tokenBudget: 100_000, @@ -71,6 +72,7 @@ test('a paused goal reads as paused and offers resume, not pause', () => { status: 'paused', iterations: 3, maxIterations: 50, + isArmed: false, setAt: 1_000, pausedAt: 1_000 + 12 * 60_000, onResume: () => undefined, @@ -91,6 +93,7 @@ test('a waiting goal reads as waiting without looking active or paused', () => { status: 'waiting', iterations: 4, maxIterations: 50, + isArmed: false, setAt: Date.now() - 30_000, tokensSpent: 12_000, tokenBudget: 100_000, @@ -104,3 +107,51 @@ test('a waiting goal reads as waiting without looking active or paused', () => { assert.ok(!markup.includes('Resume autonomous goal')); assert.ok(markup.includes('12k / 100k')); }); + +test('an armed goal waits for its first Turn without looking like it is running', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'active', + isArmed: true, + iterations: 0, + maxIterations: 50, + setAt: Date.now() - 30_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal set; takes hold on the next Turn')); + assert.ok(!markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal paused')); + assert.ok(markup.includes('Clear autonomous goal after 0/50 iterations')); +}); + +test('a bound first Turn makes an armed goal read as running', () => { + const markup = renderGoalChip({ + condition: 'Ship the feature', + status: 'active', + isArmed: false, + iterations: 0, + maxIterations: 50, + setAt: Date.now() - 30_000, + onPause: () => undefined, + onClear: () => undefined, + }); + assert.ok(markup.includes('Autonomous goal running')); + assert.ok(!markup.includes('Autonomous goal set; takes hold on the next Turn')); +}); + +test('redacts secrets from the visible Goal condition', () => { + const secret = 'sk-ant-api03-abc123def456ghi789jkl0mn1opq'; + const markup = renderGoalChip({ + condition: `Use Authorization: Bearer ${secret}`, + status: 'waiting', + iterations: 4, + maxIterations: 50, + isArmed: false, + setAt: Date.now() - 30_000, + onClear: () => undefined, + }); + + assert.equal(markup.includes(secret), false); + assert.ok(markup.includes('Authorization: Bearer <redacted>')); +}); diff --git a/packages/ui/src/conversation-copy.ts b/packages/ui/src/conversation-copy.ts index 2e69a61201..028f02cd17 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -33,6 +33,7 @@ import { } from '@maka/core/deep-research'; export type DayPeriod = 'morning' | 'noon' | 'afternoon' | 'evening'; +export type GoalDisplayPhase = 'armed' | 'running' | 'waiting' | 'paused'; type ResearchItem = Readonly<{ title: string; body: string }>; type ResearchOption = Readonly<{ label: string; body: string }>; type ResearchStarter = Readonly<{ label: string; prompt: string }>; @@ -369,15 +370,21 @@ export interface ConversationCopy { noBlockers: string; sectionLabels: Record; }; - clearGoal: (condition: string, iteration: number, max: number, status: string) => string; + clearGoal: (condition: string, iteration: number, max: number, phase: GoalDisplayPhase) => string; clearGoalAriaLabel: (iteration: number, max: number) => string; goalProgress: (iteration: number, max: number) => string; goalRunningAriaLabel: string; + goalArmedAriaLabel: string; goalWaitingAriaLabel: string; goalPausedAriaLabel: string; pauseGoalAriaLabel: (iteration: number, max: number) => string; resumeGoalAriaLabel: (iteration: number, max: number) => string; - pauseGoal: (condition: string, iteration: number, max: number, status: string) => string; + pauseGoal: ( + condition: string, + iteration: number, + max: number, + phase: GoalDisplayPhase, + ) => string; resumeGoal: (condition: string, iteration: number, max: number) => string; /** Wall-clock elapsed label for the goal chip, e.g. "12m". */ goalElapsed: (elapsedMs: number) => string; @@ -603,8 +610,8 @@ const CONVERSATION_COPY = { verification: '验证', }, }, - clearGoal: (condition, iteration, max, status) => `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${status})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalWaitingAriaLabel: '自主目标正在等待条件变化', - goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, status) => `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${status})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, + clearGoal: (condition, iteration, max, phase) => phase === 'armed' ? `自主目标已设置:「${condition}」(第 ${iteration}/${max} 轮)。尚未开始;点击可清除目标。` : `自主执行目标进行中:「${condition}」(第 ${iteration}/${max} 轮,${phase})。系统每轮后自动续行;点击可清除目标、停止续行。`, clearGoalAriaLabel: (iteration, max) => `清除自主执行目标(已进行 ${iteration}/${max} 轮)`, goalProgress: (iteration, max) => `目标 ${iteration} / ${max}`, goalRunningAriaLabel: '自主目标正在运行', goalArmedAriaLabel: '自主目标已设置,将在下一轮接管', goalWaitingAriaLabel: '自主目标正在等待条件变化', + goalPausedAriaLabel: '自主目标已暂停', pauseGoalAriaLabel: (iteration, max) => `暂停自主执行目标(已进行 ${iteration}/${max} 轮)`, resumeGoalAriaLabel: (iteration, max) => `恢复自主执行目标(已进行 ${iteration}/${max} 轮)`, pauseGoal: (condition, iteration, max, phase) => phase === 'armed' ? `暂停自主目标:「${condition}」(第 ${iteration}/${max} 轮)。除非恢复,否则不会在下一轮接管。` : `暂停自主执行目标:「${condition}」(第 ${iteration}/${max} 轮,${phase})。暂停后立即停止自动续行,不再消耗令牌;可随时恢复。`, resumeGoal: (condition, iteration, max) => `恢复自主执行目标:「${condition}」(第 ${iteration}/${max} 轮)。恢复后立即继续自动续行。`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: ' 秒', minute: ' 分钟', hour: ' 小时', day: ' 天' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: '任务载入失败', loading: '载入中…', retryLoad: '重试载入', quoteSelection: '引用', askInSidePanel: '在侧栏追问', noMessages: '暂无消息', branchBeforeInterrupt: '从中断前分支', sessionContextAriaLabel: '任务上下文', sessionLineageAriaLabel: '任务来源', sessionContextMore: (count) => `更多任务上下文(${count})`, titlebarIdentityAriaLabel: '当前任务', openProjectFolder: (name) => `在文件管理器中打开「${name}」`, openProjectFolderAction: '打开项目文件夹', @@ -781,8 +788,8 @@ const CONVERSATION_COPY = { verification: 'Verification', }, }, - clearGoal: (condition, iteration, max, status) => `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${status}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', - goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, status) => `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${status}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, + clearGoal: (condition, iteration, max, phase) => phase === 'armed' ? `Autonomous goal is set: “${condition}” (iteration ${iteration}/${max}). The goal has not started; click to clear it.` : `Autonomous goal in progress: “${condition}” (iteration ${iteration}/${max}, ${phase}). Maka continues after each iteration; click to clear the goal and stop continuing.`, clearGoalAriaLabel: (iteration, max) => `Clear autonomous goal after ${iteration}/${max} iterations`, goalProgress: (iteration, max) => `Goal ${iteration} of ${max}`, goalRunningAriaLabel: 'Autonomous goal running', goalArmedAriaLabel: 'Autonomous goal set; takes hold on the next Turn', goalWaitingAriaLabel: 'Autonomous goal waiting for conditions to change', + goalPausedAriaLabel: 'Autonomous goal paused', pauseGoalAriaLabel: (iteration, max) => `Pause autonomous goal after ${iteration}/${max} iterations`, resumeGoalAriaLabel: (iteration, max) => `Resume autonomous goal after ${iteration}/${max} iterations`, pauseGoal: (condition, iteration, max, phase) => phase === 'armed' ? `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}). It will not take hold on the next Turn unless resumed.` : `Pause autonomous goal: “${condition}” (iteration ${iteration}/${max}, ${phase}). Pausing stops autonomous continuation immediately — no more tokens burn; resume any time.`, resumeGoal: (condition, iteration, max) => `Resume autonomous goal: “${condition}” (iteration ${iteration}/${max}). Resuming continues autonomous iteration immediately.`, goalElapsed: (elapsedMs) => formatGoalElapsedUnits(elapsedMs, { second: 's', minute: 'm', hour: 'h', day: 'd' }), goalTokens: (spent, budget) => `${formatCompactTokenCount(spent)} / ${formatCompactTokenCount(budget)}`, loadFailed: 'Task failed to load', loading: 'Loading…', retryLoad: 'Retry', quoteSelection: 'Quote', askInSidePanel: 'Ask in side panel', noMessages: 'No messages yet', branchBeforeInterrupt: 'Branched before interruption', sessionContextAriaLabel: 'Task context', sessionLineageAriaLabel: 'Task origin', sessionContextMore: (count) => `More task context (${count})`, titlebarIdentityAriaLabel: 'Current task', openProjectFolder: (name) => `Open “${name}” in the file manager`, openProjectFolderAction: 'Open project folder', diff --git a/packages/ui/src/session-context-layer.tsx b/packages/ui/src/session-context-layer.tsx index ddc9b62e28..7fbe69f5db 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -33,9 +33,10 @@ import { Tooltip, type DropdownMenuOption, } from '@astryxdesign/core'; -import { getConversationCopy } from './conversation-copy.js'; +import { getConversationCopy, type GoalDisplayPhase } from './conversation-copy.js'; import { ICON_SIZE, Pause, Play } from './icons.js'; import { useUiLocale } from './locale-context.js'; +import { redactSecrets } from './redact.js'; import { dotForStatus } from './status-vocabulary.js'; export interface SessionContextBranch { @@ -55,8 +56,10 @@ interface SessionContextGoalBase { condition: string; iterations: number; maxIterations: number; - /** Epoch ms when the goal was armed; the chip derives wall-clock elapsed. */ + /** Epoch ms when the goal was set; the chip derives wall-clock elapsed. */ setAt: number; + /** Whether the Goal is set and waiting for its first Turn. */ + isArmed: boolean; tokensSpent?: number; /** When present (a budget exists), the chip shows spent / budget. */ tokenBudget?: number; @@ -112,17 +115,26 @@ export function SessionContextLayer(props: { if (props.goal) { const goal = props.goal; + const condition = redactSecrets(goal.condition); // A paused goal burns nothing, while waiting remains live but is not // currently executing. Both must be visually still; only paused needs an // attention tone. const paused = goal.status === 'paused'; const waiting = goal.status === 'waiting'; + const armed = goal.isArmed; + const phase: GoalDisplayPhase = paused + ? 'paused' + : armed + ? 'armed' + : waiting + ? 'waiting' + : 'running'; const elapsedMs = paused ? Math.max(0, goal.pausedAt - goal.setAt) : Math.max(0, Date.now() - goal.setAt); const goalText = [ copy.goalProgress(goal.iterations, goal.maxIterations), - copy.goalElapsed(elapsedMs), + ...(armed ? [] : [copy.goalElapsed(elapsedMs)]), goal.tokenBudget !== undefined && goal.tokensSpent !== undefined ? copy.goalTokens(goal.tokensSpent, goal.tokenBudget) : null, @@ -158,11 +170,13 @@ export function SessionContextLayer(props: { label={ paused ? copy.goalPausedAriaLabel + : armed + ? copy.goalArmedAriaLabel : waiting ? copy.goalWaitingAriaLabel : copy.goalRunningAriaLabel } - isPulsing={!paused && !waiting} + isPulsing={!paused && !armed && !waiting} /> {goalText} @@ -176,10 +190,10 @@ export function SessionContextLayer(props: { size="sm" onClick={goal.onPause} tooltip={copy.pauseGoal( - goal.condition, + condition, goal.iterations, goal.maxIterations, - goal.status, + phase, )} /> ) : null} @@ -191,7 +205,7 @@ export function SessionContextLayer(props: { variant="ghost" size="sm" onClick={goal.onResume} - tooltip={copy.resumeGoal(goal.condition, goal.iterations, goal.maxIterations)} + tooltip={copy.resumeGoal(condition, goal.iterations, goal.maxIterations)} /> ) : null} @@ -359,10 +373,10 @@ export function SessionContextLayer(props: { this one branched FROM. */}
{props.goal ? ( - +
- {props.goal.condition} + {redactSecrets(props.goal.condition)}