diff --git a/apps/desktop/src/main/__tests__/goal-controller.test.ts b/apps/desktop/src/main/__tests__/goal-controller.test.ts index d063a15fef..166ad21e31 100644 --- a/apps/desktop/src/main/__tests__/goal-controller.test.ts +++ b/apps/desktop/src/main/__tests__/goal-controller.test.ts @@ -247,6 +247,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?.armedAt, 150); + + 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?.armedAt, undefined); + }); + 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 30c18dc378..fd4352cd26 100644 --- a/apps/desktop/src/main/__tests__/goal-dialog.test.ts +++ b/apps/desktop/src/main/__tests__/goal-dialog.test.ts @@ -120,6 +120,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 9ef2232e37..b3e9aceb56 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 @@ -892,6 +892,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 ee817a5027..582cc5023a 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 b022782d46..17e788b9fa 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 @@ -2402,6 +2402,8 @@ test("publishes Host sidecar and graph invalidations without inventing Session s lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, }, }), }); @@ -2510,6 +2512,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 c0fd5aaa38..e86b379262 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/desktop-goal-state.js'; import { projectHostedDeepResearch } from './deep-research-desktop-projection.js'; import { handleReconciledControl, @@ -458,7 +459,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, @@ -477,6 +478,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 5d42106c01..36e99c5e8d 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -914,7 +914,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 883adb1f93..7c93a45ea4 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -181,7 +181,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/desktop-goal-state.js'; import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; import type { ConfigCategory } from '@maka/storage'; import { @@ -2122,7 +2122,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..e3aafeb623 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,9 @@ export function useGoalController( iterations: activeGoal.iterations, maxIterations: activeGoal.maxIterations, setAt: activeGoal.setAt, + ...(isGoalArmedAwaitingFirstTurn(activeGoal) + ? { armedAt: activeGoal.armedAt } + : {}), 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..4b832d206a 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/desktop-goal-state.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..a4bad456c3 100644 --- a/apps/desktop/src/renderer/features/goals/ports.ts +++ b/apps/desktop/src/renderer/features/goals/ports.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { GoalState } from '@maka/core/goal'; +import type { DesktopGoalState } from '../../../shared/desktop-goal-state.js'; import type { GoalArmOutcome } from '../../../shared/goal-arm.js'; export type { GoalArmOutcome } from '../../../shared/goal-arm.js'; @@ -32,7 +32,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/desktop-goal-state.ts b/apps/desktop/src/shared/desktop-goal-state.ts new file mode 100644 index 0000000000..fb3e4344e8 --- /dev/null +++ b/apps/desktop/src/shared/desktop-goal-state.ts @@ -0,0 +1,25 @@ +/* + * 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 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; +}; diff --git a/apps/desktop/src/shared/goal-arm.ts b/apps/desktop/src/shared/goal-arm.ts index cf5d5d24f4..2a76f4078b 100644 --- a/apps/desktop/src/shared/goal-arm.ts +++ b/apps/desktop/src/shared/goal-arm.ts @@ -17,7 +17,7 @@ * under the License. */ -import type { GoalState } from '@maka/runtime/goal-state'; +import type { DesktopGoalState } from './desktop-goal-state.js'; /** * What the renderer sends to arm a Goal. @@ -34,10 +34,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/packages/cli/src/__tests__/pi-goal.test.ts b/packages/cli/src/__tests__/pi-goal.test.ts index 0dcf8484bb..a63f003e86 100644 --- a/packages/cli/src/__tests__/pi-goal.test.ts +++ b/packages/cli/src/__tests__/pi-goal.test.ts @@ -32,6 +32,7 @@ import { goalStatusLineText, goalSummaryLines, isLiveGoalStatus, + shouldAnnounceGoalAttachment, } from '../pi-goal.js'; function goal(overrides: Partial = {}): GoalProjection { @@ -51,6 +52,8 @@ function goal(overrides: Partial = {}): GoalProjection { lastReason: null, achievedAt: null, pausedAt: null, + armedAt: null, + boundTurnId: null, ...overrides, }; } @@ -99,6 +102,28 @@ 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.', + ); + assert.equal(shouldAnnounceGoalAttachment(armed), false); + assert.equal(shouldAnnounceGoalAttachment(goal()), true); + }); + + 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.equal(shouldAnnounceGoalAttachment(running), true); + 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 +209,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 72003776aa..a762768b0a 100644 --- a/packages/cli/src/__tests__/pi-transcript.test.ts +++ b/packages/cli/src/__tests__/pi-transcript.test.ts @@ -155,6 +155,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), diff --git a/packages/cli/src/__tests__/pi-tui-runner.test.ts b/packages/cli/src/__tests__/pi-tui-runner.test.ts index 930513d668..e79878bb78 100644 --- a/packages/cli/src/__tests__/pi-tui-runner.test.ts +++ b/packages/cli/src/__tests__/pi-tui-runner.test.ts @@ -5730,6 +5730,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 () => { 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 1b9dc7abb1..d75a5e0248 100644 --- a/packages/cli/src/__tests__/runtime-host-session-driver.test.ts +++ b/packages/cli/src/__tests__/runtime-host-session-driver.test.ts @@ -1844,6 +1844,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..283441dff4 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,33 @@ 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.`; } +export function shouldAnnounceGoalAttachment( + goal: Pick, +): boolean { + return (goal.status === 'active' || goal.status === 'waiting') && !isArmedGoal(goal); +} + /** 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 +193,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/cli/src/pi-tui-runner.ts b/packages/cli/src/pi-tui-runner.ts index 79c67bd6ed..55accee6b8 100644 --- a/packages/cli/src/pi-tui-runner.ts +++ b/packages/cli/src/pi-tui-runner.ts @@ -144,6 +144,7 @@ import { goalStatusLabel, goalSummaryLines, isLiveGoalStatus, + shouldAnnounceGoalAttachment, } from './pi-goal.js'; import { getTuiPrimaryGuidance } from './tui-primary-guidance.js'; import type { GoalControlAction, GoalProjection } from '@maka/runtime-host/protocol'; @@ -428,10 +429,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // must never resume a token-burning loop silently. This covers a driver // that is already attached at startup; a resumeSessionId attach happens // later, so switchSession repeats the check after adopting the session. - if ( - currentGoal !== null && - (currentGoal.status === 'active' || currentGoal.status === 'waiting') - ) { + if (currentGoal !== null && shouldAnnounceGoalAttachment(currentGoal)) { state.entries.push({ kind: 'notice', level: 'info', @@ -1562,10 +1560,7 @@ export async function runMakaPiTui(input: MakaPiTuiInput): Promise { // would erase a notice from adoption time — keeps an auto-continuing // token-burning loop from resuming silently. currentGoal = input.driver.getGoal?.() ?? null; - if ( - currentGoal !== null && - (currentGoal.status === 'active' || currentGoal.status === 'waiting') - ) { + if (currentGoal !== null && shouldAnnounceGoalAttachment(currentGoal)) { state.entries.push({ kind: 'notice', level: 'info', 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 497aeb1290..a452aa8995 100644 --- a/packages/runtime-host/src/__tests__/goal-coordinator.test.ts +++ b/packages/runtime-host/src/__tests__/goal-coordinator.test.ts @@ -599,6 +599,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( @@ -609,6 +611,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']( @@ -652,6 +655,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 }); @@ -751,6 +755,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 146c199f07..1e6b0ba1b8 100644 --- a/packages/runtime-host/src/__tests__/protocol.test.ts +++ b/packages/runtime-host/src/__tests__/protocol.test.ts @@ -97,6 +97,12 @@ describe('Runtime Host bootstrap protocol', () => { assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 22); }); + 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. + assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 42); + }); + 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 eb63d7d0ab..be0877cd26 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -91,7 +91,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 = 44 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 45 as const; +// 45: 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. // 44: Session continuity and inspection stop carrying the retired Session // last-used timestamp. Older peers reject those strict projection shapes. // 43: Session tool-start events correlate hidden shell polls with `shellRunRef`. diff --git a/packages/runtime-host/src/server/goal-coordinator.ts b/packages/runtime-host/src/server/goal-coordinator.ts index 9949bae516..b4dc330033 100644 --- a/packages/runtime-host/src/server/goal-coordinator.ts +++ b/packages/runtime-host/src/server/goal-coordinator.ts @@ -238,11 +238,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 { @@ -251,6 +255,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..449a0a5b6b 100644 --- a/packages/ui/src/__tests__/session-context-layer-goal.test.tsx +++ b/packages/ui/src/__tests__/session-context-layer-goal.test.tsx @@ -104,3 +104,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', + armedAt: Date.now() - 30_000, + 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', + armedAt: Date.now() - 30_000, + boundTurnId: 'turn-1', + 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, + 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 983089caa1..070fb8ce6e 100644 --- a/packages/ui/src/conversation-copy.ts +++ b/packages/ui/src/conversation-copy.ts @@ -33,6 +33,7 @@ import { } from '@maka/core/explore-agent'; 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 }>; @@ -309,15 +310,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; @@ -505,8 +512,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: '打开项目文件夹', @@ -653,8 +660,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..fd7dd35955 100644 --- a/packages/ui/src/session-context-layer.tsx +++ b/packages/ui/src/session-context-layer.tsx @@ -18,6 +18,7 @@ */ import { useEffect, useReducer, type ReactElement } from 'react'; +import { isGoalArmedAwaitingFirstTurn } from '@maka/core/goal'; import { BreadcrumbItem, Breadcrumbs, @@ -33,9 +34,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 +57,12 @@ 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; + /** Present while a user-armed Goal waits for its first Turn. */ + armedAt?: number; + /** Present while the first Turn carrying an armed Goal is running. */ + boundTurnId?: string | null; tokensSpent?: number; /** When present (a budget exists), the chip shows spent / budget. */ tokenBudget?: number; @@ -112,17 +118,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 = isGoalArmedAwaitingFirstTurn(goal); + 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 +173,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 +193,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 +208,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 +376,10 @@ export function SessionContextLayer(props: { this one branched FROM. */}
{props.goal ? ( - +
- {props.goal.condition} + {redactSecrets(props.goal.condition)}