From 9da5c12fac5d6afb1c8210a2126a2aa4f43be043 Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:35:51 +0000 Subject: [PATCH 1/9] fix: control Codex goals through the ACP extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pausing or resuming a goal was delivered as a chat message (`/goal pause`, `/goal resume`), so it needed the session's ACP prompt slot — the same slot the running goal holds open across the agent's own continuations. The dispatch watcher deferred those turns with `guard-noop-active-session` and the banner disabled every button while it waited, so a paused goal's Resume button did nothing until the user pressed Stop. Goal actions now split by what they do. `pause` and `clear` move durable state only, so they travel out of band on `_lody/session/goal` and land while a prompt is running. `set` and `resume` start work, so they ride a Lody-owned turn carrying `_meta.lody.goalControl`: no command text in the conversation, and the resulting turns still belong to an assistant entry the user can see and cancel. An action that arrives while a turn is draining waits for that turn instead of being dropped, newest action wins, and the banner's pending state now expires. Goal buttons follow the runtime's advertised `goalActions` instead of an `agentType === 'codex'` check, so any agent implementing the extension gets them. Model: claude-opus-5 Co-Authored-By: Claude Opus 5 --- .../2026-09-09-goal-control-plane.md | 124 ++++++++++ apps/cli/src/agent/acp-capabilities.test.ts | 23 ++ apps/cli/src/agent/acp-capabilities.ts | 1 + .../src/agent/acp-capability-normalization.ts | 6 + apps/cli/src/agent/agent-client.ts | 100 +++++++- apps/cli/src/agent/goal-control.test.ts | 73 ++++++ apps/cli/src/agent/goal-control.ts | 71 ++++++ apps/cli/src/agent/lody-acp-extension.ts | 8 +- apps/cli/src/lib/loro/doc.ts | 5 + .../machine-document-capabilities.test.ts | 1 + apps/cli/src/lib/message-handler.ts | 40 ++++ .../src/session/session-execution-service.ts | 222 +++++++++++++++++- apps/cli/src/session/session.ts | 1 + .../tests/session-execution-service.test.ts | 143 ++++++++++- packages/acp-extension-codex | 2 +- packages/acp-extension-core | 2 +- packages/components/src/atoms/runtime.ts | 13 + .../sessions/session-chat-interface.tsx | 60 +++-- .../sessions/session-goal-control.ts | 47 ++-- .../src/hooks/use-session-actions.ts | 44 ++++ .../src/providers/create-workspace-runtime.ts | 2 + .../providers/workspace-machine-rpc-facade.ts | 47 ++++ .../tests/session-goal-control.test.ts | 37 +-- .../src/machine-rpc-server.ts | 27 +++ packages/loro-streams-rpc/src/rpc.ts | 79 +++++++ packages/shared/AGENTS.md | 12 + packages/shared/src/ai.ts | 9 +- packages/shared/src/goal.ts | 9 + packages/shared/src/local-machine-rpc.ts | 15 ++ packages/shared/src/message-schemas.ts | 14 ++ packages/shared/src/message.ts | 20 ++ .../shared/tests/ai-capability-cache.test.ts | 4 +- specs/session-goal-control.md | 94 ++++++++ 33 files changed, 1293 insertions(+), 62 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md create mode 100644 apps/cli/src/agent/goal-control.test.ts create mode 100644 apps/cli/src/agent/goal-control.ts create mode 100644 specs/session-goal-control.md diff --git a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md new file mode 100644 index 000000000..9dcce2c8e --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -0,0 +1,124 @@ +# Give goal actions their own control plane instead of the `/goal` prompt bridge + +Status: implemented +Translation: pending + +## Abstract + +Pausing or resuming a Codex goal was delivered as a chat message (`/goal pause`, +`/goal resume`), so it needed the session's ACP prompt slot — the same slot the +running goal holds open across the agent's own continuations. The dispatch +watcher silently deferred those turns with `guard-noop-active-session`, and the +goal banner disabled every button while it waited, so the visible symptom was a +paused goal whose Resume button did nothing until the user pressed Stop. Goal +actions now split by what they do: `pause` and `clear` travel out of band on the +`_lody/session/goal` extension request and take effect mid-prompt, while `set` +and `resume` ride a Lody-owned prompt as `_meta.lody.goalControl` metadata and +queue behind a draining turn instead of being dropped. The split is a protocol +change across `acp-extension-core` and the Codex adapter; it was verified with +adapter, CLI, and component tests, not against a live Codex goal. + +## Problem + +`GoalPromptLifecycle` (Codex adapter, September 2026) made one ACP v1 prompt span +every native continuation of an active goal. That is the right lifecycle — it is +how the goal's turns stay attributable to one conversation entry — but it means +an active goal permanently occupies the session's only prompt slot. + +Lody's goal controls were prompt text. Each press wrote a pending user turn and +asked the CLI to dispatch it. `resolveSessionDispatchAction` returns +`noop('active-session')` whenever a turn is active, so the turn sat pending until +the goal's prompt closed. Meanwhile the banner's `pendingGoalCommand` only cleared +when the goal status actually changed, with no timeout, and it disabled all goal +buttons. Cancelling the turn was the only user-reachable way out, which is exactly +the workaround users found. + +Pause had already accumulated compensations for the same root cause: the Stop +button sent its own `/goal pause`, and the adapter paused the goal itself in the +cancelled-prompt path. Both exist because the bridge could not deliver a command +during a prompt. + +## Decision + +Split goal actions by whether they start work. + +Status-only actions (`pause`, `clear`) go out of band. `acp-extension-core` +already defined `_lody/session/goal`; the capability now names which actions are +safe there (`controlActions`) and Lody finally calls it. No turn, no prompt slot, +no queue. + +Work-starting actions (`set`, `resume`) need somewhere to put the resulting +turns, and ACP v1 gives a client exactly one such place: its own prompt. Core +gained `LodyGoalPromptControl`, carried on `prompt._meta.lody.goalControl`, and +the adapter routes it into the same code as the slash command. The conversation +never carries command text, and the adapter still adopts a turn Codex started +natively rather than submitting a duplicate. + +The CLI owns the ordering. `SessionExecutionService.controlSessionGoal` sends the +request when the transport allows it; otherwise it opens a goal turn +(`dispatchSource: 'goal'`, no user message, no run configuration) or queues one +behind the running turn using the existing `waitForTurnRelease`. One queued action +per session, newest wins, bounded to three turn waits. + +The UI now reads `goalActions` from the ACP capability cache instead of testing +`agentType === 'codex'`, and its pending state expires after a minute so a slow +action cannot leave the banner dead. + +## Alternatives + +**Let the adapter start the resumed goal's turn itself.** The adapter already has +`startGoalContinuationIfCurrent` for clients that cannot send prompt metadata. +Rejected for Lody: the CLI would receive session updates for a turn it never +prompted, and `SessionTransientStore`'s late-update routing would append them to +the previously finalized assistant entry — no turn boundary, no running status, no +Stop button. + +**Keep the prompt bridge and only fix the queueing.** This would have removed the +dead Resume button without a protocol change, but pause would still be undeliverable +during the prompt it needs to stop, and Stop's `/goal pause` compensation would +have to stay. + +**Allow only `set`/`resume` as prompt metadata.** Cleaner conceptually, but it +leaves no way to clear a goal whose session is not running: the request path needs +a live agent. Status-only actions are therefore accepted on both transports, with +the request preferred. + +## Consequences and limits + +`ACP_CAPABILITY_CACHE_VERSION` moves to 8 so machines re-probe and publish +`goalActions`. Until a session's machine refreshes, goal buttons are hidden rather +than wrongly shown — the conservative direction. + +`acp-extension-core` is now 0.1.2 and the Codex adapter depends on that version. +Inside this workspace the pnpm override resolves it to the local source; a +published adapter build needs the new core release first. + +A paused goal can still be draining its last native turn, and that is now visible +rather than hidden: the queued resume waits and then runs. It no longer requires +Stop, but it is not instant either. + +Both submodules need their own PRs before the pointer bump here lands. +The [goal control Spec](../../../../specs/session-goal-control.md) is draft. + +## Verification + +Adapter: prompt metadata resumes a goal without command text and without a second +`turnStart`; the parser accepts every advertised action and rejects a blank +objective, an unknown action, and a future version. The existing goal-lifecycle, +transport, and thread-event suites still pass (37 tests). + +CLI: transport selection prefers the request for status-only actions, keeps +work-starting actions on a prompt even when the agent lists them as control +actions, falls back to the slash bridge for runtimes advertising neither list, and +refuses unadvertised actions. Execution-service tests cover out-of-band pause +without a turn, a refused action, a goal turn carrying no run configuration, a +resume queued behind a draining turn and released deterministically by clearing +the current turn (no timers, no sleeps), and newest-wins supersession. The CLI +suite passes except `tests/gh-shim-script.test.ts`, which fails in this sandbox on +files this change does not touch. + +Components: goal commands derive from advertised actions, including a partial +advertisement. Typecheck passes for shared, RPC, CLI, and components. + +Not verified: a live Codex session pausing and resuming a real goal, and the +managed-runtime build path that consumes a published `acp-extension-core`. diff --git a/apps/cli/src/agent/acp-capabilities.test.ts b/apps/cli/src/agent/acp-capabilities.test.ts index a9fa90408..432b2fad2 100644 --- a/apps/cli/src/agent/acp-capabilities.test.ts +++ b/apps/cli/src/agent/acp-capabilities.test.ts @@ -36,6 +36,7 @@ function createSuccessfulStartupResult(sessionResponse?: Record agentProcess: {} as never, client: { supportsAcknowledgedSteer: () => false, + getGoalCapability: () => undefined, } as never, acpSessionId: 'acp-session-1' as never, sessionResponse: sessionResponse ?? { @@ -129,10 +130,32 @@ describe('fetchAcpCapabilities', () => { expect(result.availableCommands).toEqual([{ name: '/help', description: 'Help' }]); }); + it('records the goal actions the live client advertised', async () => { + const startupResult = createSuccessfulStartupResult(); + startupResult.client = { + supportsAcknowledgedSteer: () => false, + getGoalCapability: () => ({ version: 1, actions: ['pause', 'resume'] }), + } as never; + mocks.startLocalAcpAgent.mockResolvedValue(startupResult); + + const result = await fetchAcpCapabilities('registry', 'goal-agent', createSilentLogger()); + + expect(result.goalActions).toEqual(['pause', 'resume']); + }); + + it('leaves goal actions absent for a runtime with no goal extension', async () => { + mocks.startLocalAcpAgent.mockResolvedValue(createSuccessfulStartupResult()); + + const result = await fetchAcpCapabilities('registry', 'plain-agent', createSilentLogger()); + + expect(result.goalActions).toBeUndefined(); + }); + it('preserves acknowledged steering support discovered from the live client', async () => { const startupResult = createSuccessfulStartupResult(); startupResult.client = { supportsAcknowledgedSteer: () => true, + getGoalCapability: () => undefined, } as never; mocks.startLocalAcpAgent.mockResolvedValue(startupResult); diff --git a/apps/cli/src/agent/acp-capabilities.ts b/apps/cli/src/agent/acp-capabilities.ts index 6880828c1..fb4f0fe2a 100644 --- a/apps/cli/src/agent/acp-capabilities.ts +++ b/apps/cli/src/agent/acp-capabilities.ts @@ -92,6 +92,7 @@ export async function fetchAcpCapabilities( ...normalizeAcpSessionCapabilities(sessionResponse, { sessionFork: client.supportsSessionFork?.() === true, acknowledgedSteer: client.supportsAcknowledgedSteer(), + goalActions: client.getGoalCapability()?.actions.slice(), agent: { cliType, agentType }, }), capabilitySourceVersion, diff --git a/apps/cli/src/agent/acp-capability-normalization.ts b/apps/cli/src/agent/acp-capability-normalization.ts index 69dc67b84..25ba2ee64 100644 --- a/apps/cli/src/agent/acp-capability-normalization.ts +++ b/apps/cli/src/agent/acp-capability-normalization.ts @@ -2,6 +2,7 @@ import { deriveModelReasoningEffortsFromLegacyModelIds, type AcpCommandSummary, type AcpConfigOptionSummary, + type SessionGoalAction, } from '@lody/shared'; import type { SessionConfigOption, SessionConfigSelectGroup } from '@agentclientprotocol/sdk'; import { z } from 'zod'; @@ -14,6 +15,7 @@ export type AcpCapabilitiesResult = { availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; + goalActions?: SessionGoalAction[]; modelReasoningEfforts?: Record; }; @@ -190,6 +192,7 @@ export function normalizeAcpSessionCapabilities( lifecycleCapabilities: { sessionFork?: boolean; acknowledgedSteer?: boolean; + goalActions?: SessionGoalAction[]; /** The agent that answered; decides whether legacy `model[effort]` ids apply. */ agent?: { cliType: string; agentType: string }; } = {} @@ -227,6 +230,9 @@ export function normalizeAcpSessionCapabilities( availableCommands, sessionFork: lifecycleCapabilities.sessionFork === true, acknowledgedSteer: lifecycleCapabilities.acknowledgedSteer === true, + ...(lifecycleCapabilities.goalActions?.length + ? { goalActions: lifecycleCapabilities.goalActions } + : {}), ...(Object.keys(modelReasoningEfforts).length > 0 ? { modelReasoningEfforts } : {}), }; } diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 1a86f5c99..32108fd45 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -10,6 +10,7 @@ import { LODY_TOOL_NAMES, type LodyExtensionCapabilities, type LodyElicitationMeta, + type LodyGoalCapability, type LodySubagentTask, type RateLimit, type RateLimitsGetRequest, @@ -23,6 +24,7 @@ import { type AcpConfigOptionValue, type AcpSessionNotification, type AgentConfigCliType, + type SessionGoalAction, type SessionGoalContent, type SessionTurnInputConfig, sanitizeGoalObjective, @@ -80,6 +82,14 @@ import { parseLodyExtensionMessage, parseRateLimitsSnapshot, } from './lody-acp-extension'; +import { + buildGoalPromptMeta, + buildGoalSlashCommandText, + resolveGoalActionTransport, + GOAL_CONTROL_METHOD, + type GoalActionTransport, + type GoalPromptControl, +} from './goal-control'; /** * Checks if an error is a transport-related error that may be transient. @@ -1357,6 +1367,75 @@ export class AgentClient implements acp.Client { return z.string().parse(result.output); } + getGoalCapability(): LodyGoalCapability | undefined { + return this.lodyExtensionCapabilities.goal; + } + + resolveGoalActionTransport(action: SessionGoalAction): GoalActionTransport | null { + return resolveGoalActionTransport(this.lodyExtensionCapabilities.goal, action); + } + + /** + * Move durable goal state without a turn. + * + * An active goal holds this session's single ACP prompt open across the + * agent's own continuations, so a pause or clear that had to wait for a free + * prompt slot would wait for the very thing it is trying to stop. The agent + * publishes the resulting snapshot on its own session update; this response is + * only the acknowledgement that the action landed. + */ + async controlGoal(action: SessionGoalAction): Promise { + if (this.resolveGoalActionTransport(action) !== 'request') { + throw new Error( + `[ACP_GOAL_UNSUPPORTED] Agent did not advertise out-of-band goal control for ${action}` + ); + } + const sessionId = this.acpSessionId; + const connection = this.connection; + if (!sessionId || !connection) { + throw new Error('[ACP_GOAL_UNAVAILABLE] ACP session is not connected'); + } + const response = await connection.request( + GOAL_CONTROL_METHOD, + { sessionId, action } + ); + const parsed = z + .object({ goal: LodyGoalSnapshotSchema.nullable().optional() }) + .safeParse(response ?? {}); + if (!parsed.success) { + throw new Error( + `[ACP_GOAL_INVALID_RESPONSE] Agent returned an invalid goal control response: ${parsed.error.message}` + ); + } + this.logger.debug( + `[${this.options.sessionId}] Goal ${action} applied (status=${parsed.data.goal?.status ?? 'none'})` + ); + } + + /** + * Shape a prompt that carries a goal action. + * + * Metadata keeps the action off the transcript. A runtime that never + * advertised the metadata transport would ignore it and run the fallback + * blocks as an ordinary message, so those runtimes get the slash bridge that + * they do understand. + */ + private buildGoalControlPrompt( + prompt: acp.ContentBlock[], + control: GoalPromptControl + ): { prompt: acp.ContentBlock[]; _meta?: acp.PromptRequest['_meta'] } { + const transport = this.resolveGoalActionTransport(control.action); + if (transport === 'promptMeta') { + return { prompt, _meta: buildGoalPromptMeta(control) }; + } + if (transport === 'slashCommand') { + return { prompt: [{ type: 'text', text: buildGoalSlashCommandText(control) }] }; + } + throw new Error( + `[ACP_GOAL_UNSUPPORTED] Agent cannot run goal action ${control.action} inside a prompt` + ); + } + private async requestSubagentExtension>( method: string, params: Record @@ -2376,12 +2455,28 @@ export class AgentClient implements acp.Client { async prompt( sessionId: ACPSessionId, prompt: acp.ContentBlock[], - options?: { signal?: AbortSignal; _meta?: acp.PromptRequest['_meta'] } + options?: { + signal?: AbortSignal; + _meta?: acp.PromptRequest['_meta']; + /** + * Run a goal action inside this prompt. The action travels as metadata so + * the conversation never carries command text; runtimes that predate that + * capability get the `/goal …` bridge instead. + */ + goalControl?: GoalPromptControl; + } ) { + const goalPrompt = options?.goalControl + ? this.buildGoalControlPrompt(prompt, options.goalControl) + : null; + if (goalPrompt) { + prompt = goalPrompt.prompt; + } const span = startTraceSpan(this.logger, 'agent_client.prompt', { sessionId: this.options.sessionId, acpSessionId: sessionId, promptBlocks: prompt.length, + ...(options?.goalControl ? { goalAction: options.goalControl.action } : {}), }); this.logger.debug( `[${this.options.sessionId}] AgentClient.prompt called (acpSessionId=${sessionId})` @@ -2395,10 +2490,11 @@ export class AgentClient implements acp.Client { this.logger.debug( `[${this.options.sessionId}] Session match verified, calling connection.prompt` ); + const promptMeta = goalPrompt?._meta ?? options?._meta; const promptPromise = this.connection?.prompt({ sessionId, prompt, - ...(options?._meta ? { _meta: options._meta } : {}), + ...(promptMeta ? { _meta: promptMeta } : {}), }); if (!promptPromise) { this.logger.error( diff --git a/apps/cli/src/agent/goal-control.test.ts b/apps/cli/src/agent/goal-control.test.ts new file mode 100644 index 000000000..4a7f50057 --- /dev/null +++ b/apps/cli/src/agent/goal-control.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; +import type { LodyGoalCapability } from 'acp-extension-core'; +import { + buildGoalPromptMeta, + buildGoalSlashCommandText, + resolveGoalActionTransport, +} from './goal-control'; + +const capability = (overrides: Partial = {}): LodyGoalCapability => ({ + version: 1, + actions: ['set', 'pause', 'resume', 'clear'], + ...overrides, +}); + +describe('resolveGoalActionTransport', () => { + it('sends status-only actions out-of-band when the agent advertises them', () => { + const advertised = capability({ + controlActions: ['pause', 'clear'], + promptActions: ['set', 'pause', 'resume', 'clear'], + }); + + expect(resolveGoalActionTransport(advertised, 'pause')).toBe('request'); + expect(resolveGoalActionTransport(advertised, 'clear')).toBe('request'); + }); + + it('keeps work-starting actions inside a prompt even when the request lists them', () => { + // An agent that starts the goal's work from a bare request would produce + // turns Lody never prompted for and cannot attribute to a conversation. + const advertised = capability({ + controlActions: ['set', 'pause', 'resume', 'clear'], + promptActions: ['set', 'resume'], + }); + + expect(resolveGoalActionTransport(advertised, 'resume')).toBe('promptMeta'); + expect(resolveGoalActionTransport(advertised, 'set')).toBe('promptMeta'); + }); + + it('falls back to the slash bridge for runtimes that advertise no transports', () => { + const legacy = capability(); + + expect(resolveGoalActionTransport(legacy, 'pause')).toBe('slashCommand'); + expect(resolveGoalActionTransport(legacy, 'resume')).toBe('slashCommand'); + }); + + it('refuses actions the agent never advertised', () => { + expect(resolveGoalActionTransport(undefined, 'pause')).toBeNull(); + expect(resolveGoalActionTransport(capability({ actions: ['set'] }), 'resume')).toBeNull(); + expect( + resolveGoalActionTransport( + capability({ controlActions: ['pause'], promptActions: ['set'] }), + 'resume' + ) + ).toBeNull(); + }); +}); + +describe('goal prompt payloads', () => { + it('carries the action as metadata so no command text enters the conversation', () => { + expect(buildGoalPromptMeta({ action: 'resume' })).toEqual({ + lody: { goalControl: { version: 1, action: 'resume' } }, + }); + expect(buildGoalPromptMeta({ action: 'set', objective: 'Ship it' })).toEqual({ + lody: { goalControl: { version: 1, action: 'set', objective: 'Ship it' } }, + }); + }); + + it('writes the slash bridge text a legacy runtime understands', () => { + expect(buildGoalSlashCommandText({ action: 'resume' })).toBe('/goal resume'); + expect(buildGoalSlashCommandText({ action: 'set', objective: ' Ship it ' })).toBe( + '/goal Ship it' + ); + }); +}); diff --git a/apps/cli/src/agent/goal-control.ts b/apps/cli/src/agent/goal-control.ts new file mode 100644 index 000000000..747051975 --- /dev/null +++ b/apps/cli/src/agent/goal-control.ts @@ -0,0 +1,71 @@ +import type * as acp from '@agentclientprotocol/sdk'; +import { LODY_EXTENSION_METHODS, type LodyGoalCapability } from 'acp-extension-core'; +import { isStatusOnlySessionGoalAction, type SessionGoalAction } from '@lody/shared'; + +export const GOAL_CONTROL_METHOD = LODY_EXTENSION_METHODS.sessionGoal; + +/** + * Fallback blocks for a prompt whose goal action started no native turn. The + * agent replaces them when it schedules the continuation itself. + */ +export const GOAL_CONTINUATION_PROMPT_TEXT = 'Continue working toward the active goal.'; + +/** + * How a goal action can reach the agent. + * + * - `request`: `_lody/session/goal`, deliverable while a prompt is in flight. + * - `promptMeta`: a prompt carrying `_meta.lody.goalControl`, so the action runs + * inside a turn Lody owns without putting command text in the conversation. + * - `slashCommand`: the `/goal …` bridge, for runtimes that predate the split. + */ +export type GoalActionTransport = 'request' | 'promptMeta' | 'slashCommand'; + +export type GoalPromptControl = { + action: SessionGoalAction; + objective?: string; +}; + +/** + * Pick the transport for one action. + * + * The out-of-band request wins whenever the agent advertises it, because it is + * the only transport that does not need a turn — and a goal's own prompt can + * hold the session's single prompt slot for hours. Everything else runs inside + * a prompt so the turns it starts belong to a conversation entry. + */ +export function resolveGoalActionTransport( + capability: LodyGoalCapability | undefined, + action: SessionGoalAction +): GoalActionTransport | null { + if (!capability?.actions.includes(action)) { + return null; + } + if (capability.controlActions?.includes(action) && isStatusOnlySessionGoalAction(action)) { + return 'request'; + } + if (capability.promptActions?.includes(action)) { + return 'promptMeta'; + } + // A runtime that advertises neither list predates the split and only + // understands the slash bridge. + return capability.controlActions || capability.promptActions ? null : 'slashCommand'; +} + +export function buildGoalPromptMeta(control: GoalPromptControl): acp.PromptRequest['_meta'] { + return { + lody: { + goalControl: { + version: 1, + action: control.action, + ...(control.action === 'set' ? { objective: control.objective ?? '' } : {}), + }, + }, + }; +} + +/** The `/goal …` text a legacy runtime needs in place of prompt metadata. */ +export function buildGoalSlashCommandText(control: GoalPromptControl): string { + return control.action === 'set' + ? `/goal ${(control.objective ?? '').trim()}` + : `/goal ${control.action}`; +} diff --git a/apps/cli/src/agent/lody-acp-extension.ts b/apps/cli/src/agent/lody-acp-extension.ts index 5ca5e789d..0ea83a51d 100644 --- a/apps/cli/src/agent/lody-acp-extension.ts +++ b/apps/cli/src/agent/lody-acp-extension.ts @@ -43,6 +43,7 @@ export function getBuiltinToolPermissionOutcome(args: { } const VersionOneSchema = z.object({ version: z.literal(1) }); +const GoalActionSchema = z.enum(['set', 'pause', 'resume', 'clear']); const LodyCapabilitiesSchema = z .object({ usage: VersionOneSchema.optional(), @@ -64,7 +65,12 @@ const LodyCapabilitiesSchema = z output: z.literal(true).optional(), }).optional(), goal: VersionOneSchema.extend({ - actions: z.array(z.enum(['set', 'pause', 'resume', 'clear'])), + actions: z.array(GoalActionSchema), + // Which transport carries which action. `actions` alone cannot say, and + // sending a work-starting action out-of-band would produce turns Lody has + // nowhere to attribute. + controlActions: z.array(GoalActionSchema).optional(), + promptActions: z.array(GoalActionSchema).optional(), }).optional(), compaction: VersionOneSchema.optional(), sessionHistory: VersionOneSchema.optional(), diff --git a/apps/cli/src/lib/loro/doc.ts b/apps/cli/src/lib/loro/doc.ts index f34f45dcd..6deb89f37 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -52,6 +52,7 @@ import { writeMachineFlockRowToFlock, type AcpConfigOptionSummary, type AcpCommandSummary, + type SessionGoalAction, type AcpCapabilityCacheEntry, type SessionForkOperation, SessionForkOperationSchema, @@ -1522,6 +1523,7 @@ export class LoroDocumentManager { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, + goalActions?: SessionGoalAction[], options: { signal?: AbortSignal } = {} ): Promise { options.signal?.throwIfAborted(); @@ -1542,6 +1544,7 @@ export class LoroDocumentManager { sourceVersion, modelReasoningEfforts, acknowledgedSteer, + goalActions, options ); } @@ -3216,6 +3219,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, + goalActions?: SessionGoalAction[], options: { signal?: AbortSignal } = {} ): Promise { options.signal?.throwIfAborted(); @@ -3241,6 +3245,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { availableCommands: availableCommands?.length ? availableCommands : undefined, sessionFork, acknowledgedSteer, + goalActions: goalActions?.length ? goalActions : undefined, sessionForkWorktree: sessionFork, modelReasoningEfforts: modelReasoningEfforts && Object.keys(modelReasoningEfforts).length > 0 diff --git a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts index a9254d48f..e6c4800a2 100644 --- a/apps/cli/src/lib/loro/machine-document-capabilities.test.ts +++ b/apps/cli/src/lib/loro/machine-document-capabilities.test.ts @@ -159,6 +159,7 @@ describe('MachineDocument ACP capabilities', () => { 'builtin:codex:test', undefined, false, + undefined, { signal: controller.signal } ); await openStarted; diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index 64ed7ca2e..b7f157a12 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -133,6 +133,8 @@ import { getServerNow, CODE_COLLAB_V2_TEXT_LIMITS, isSessionGoalActive, + type SessionGoalAction, + type SessionGoalResponse, resolveLatestSessionGoalFromHistory, resolveProjectGitHubRepo, type RepoId, @@ -2769,6 +2771,36 @@ export class MessageHandler { return await this.executionService.steerSession(args); } + private async controlSessionGoalWithAccessCheck(args: { + sessionId: SessionId; + action: SessionGoalAction; + objective?: string; + userId: string; + }): Promise { + const access = await this.verifySessionMachineAccess(args.sessionId, args.userId); + if (access.outcome !== 'allowed') { + return { + type: 'session/goal_response', + sessionId: args.sessionId, + action: args.action, + accepted: false, + disposition: 'error', + error: `Goal access verification ${access.outcome}`, + }; + } + // Goal turns commit with the requester's identity, exactly like the turns a + // user message would start. + const user = await this.sessionUserResolver.resolve(args.userId); + return await this.executionService.controlSessionGoal({ + sessionId: args.sessionId, + action: args.action, + ...(args.objective ? { objective: args.objective } : {}), + userId: args.userId, + userName: user.name, + userEmail: user.email, + }); + } + private async forkSessionWithAccessCheck(args: SessionForkSpec): Promise { const access = await this.verifySessionMachineAccess( args.sourceSessionId, @@ -3392,6 +3424,7 @@ export class MessageHandler { }; }, steerSession: async (args) => await this.steerSessionWithAccessCheck(args), + controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), terminateSession: async ({ sessionId }) => await this.terminateAcpSession(sessionId), forkSession: async (args) => await this.forkSessionWithAccessCheck(args), editAndResendSession: async (args) => await this.editAndResendSessionWithAccessCheck(args), @@ -6683,6 +6716,12 @@ export class MessageHandler { sessionId: request.params.sessionId as SessionId, }); } + case 'session/goal': { + return await this.controlSessionGoalWithAccessCheck({ + ...request.params, + sessionId: request.params.sessionId as SessionId, + }); + } case 'session/terminate': return await this.terminateAcpSession(request.params.sessionId as SessionId); case 'session/fork': @@ -8400,6 +8439,7 @@ export class MessageHandler { availableCommands?: NonNullable; sessionFork: boolean; acknowledgedSteer: boolean; + goalActions?: SessionGoalAction[]; modelReasoningEfforts?: Record; capabilitySourceVersion?: string; }> { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 9a0125e37..37070d031 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -5,6 +5,8 @@ import { type AgentConfigMeta, type ChatFailedCode, type ChatFailedReason, + type SessionGoalAction, + type SessionGoalResponse, type IssuePRMention, type LocalProjectId, type MachineAcpBinaryInstallRequestValidated, @@ -58,6 +60,7 @@ import { serializeCustomAcpLaunchSpec, } from '@lody/shared'; import type { ContentBlock } from '@agentclientprotocol/sdk'; +import { randomUUID } from 'node:crypto'; import type { ModelInfo } from '@lody/shared'; import { Cause, Data, Effect, Exit, Fiber, type Scope } from 'effect'; import { @@ -88,6 +91,7 @@ import { } from '@/agent/managed-agent-runtime'; import type { FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError } from '@/agent/agent-client'; +import { GOAL_CONTINUATION_PROMPT_TEXT, type GoalPromptControl } from '@/agent/goal-control'; import { AcpAuthenticationManager, type AcpAuthenticationProgressEvent, @@ -102,7 +106,10 @@ import type { ISession, SessionManager } from './session-manager'; import type { LoroDocumentManager, SessionDocument } from '@/lib/loro/doc'; import { buildPrompt, normalizeSessionInputBlocks } from './session-execution-helpers'; import type { MemoryPressureEvictionResult } from '@/lib/session-gc-manager'; -import { resolveResumableAcpSessionId } from './session-dispatch-logic'; +import { + resolveDispatchAcpSessionId, + resolveResumableAcpSessionId, +} from './session-dispatch-logic'; import { resolveSessionLaunchConfig } from './session-launch-config-resolver'; import type { MachineAccessVerification } from './session-access-retry'; import { @@ -212,6 +219,21 @@ type PromptHandoffRun = { signalSuccessor: () => void; }; +type SessionGoalTurnRequest = { + sessionId: SessionId; + control: GoalPromptControl; + userId: string; + userName: string; + userEmail: string; +}; + +/** + * How many consecutive turns a queued goal action waits out. Each wait is one + * whole turn, so this is generous for a drain and still bounded for a session + * the user keeps chatting in. + */ +const GOAL_TURN_QUEUE_MAX_WAITS = 3; + type TurnInvocation = { /** Causal input Turn for authorization and durable provenance. */ sourceTurnId: string; @@ -225,6 +247,7 @@ type TurnRuntimeState = { turnId: string; userTurnId?: string; invocation?: TurnInvocation; + goalControl?: GoalPromptControl; session?: ISession; project?: ProjectRef; baseCommitHash?: string | null; @@ -328,6 +351,8 @@ type VisibleSessionTurnOptions = { * mutate user dispatch status or pointers. */ assistantEntryParentTurnId?: string; + /** Goal action this turn runs; the agent receives it as prompt metadata. */ + goalControl?: GoalPromptControl; onTurnStarted?: () => Promise; onTurnSettled?: (settlement: SessionTurnSettlement) => Promise; /** @@ -347,10 +372,20 @@ type VisibleSessionTurnPlan = { }; /** How the turn payload reached this machine (RPC fast path vs CRDT history vs queue promotion). */ -export type SessionDispatchSource = 'rpc' | 'crdt' | 'queue' | 'delivery'; +export type SessionDispatchSource = 'rpc' | 'crdt' | 'queue' | 'delivery' | 'goal'; + +/** + * Turns caused by Lody itself rather than by a user message. They own an + * assistant entry but must never touch user dispatch pointers, because no user + * turn is waiting on them. + */ +const isSystemCausedDispatch = (source: SessionDispatchSource | undefined): boolean => + source === 'delivery' || source === 'goal'; type SessionDispatchOptions = { dispatchSource?: SessionDispatchSource; + /** Goal action this turn exists to run; travels to the agent as prompt metadata. */ + goalControl?: GoalPromptControl; /** * Runs only after this process has synchronously claimed the per-Session * visible-turn owner. Delivery uses this to append its system cause without @@ -563,6 +598,7 @@ export type SessionExecutionServiceDeps = { availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; + goalActions?: SessionGoalAction[]; modelReasoningEfforts?: Record; capabilitySourceVersion?: string; }>; @@ -703,6 +739,9 @@ export class SessionExecutionService { private readonly rewriteBarrierSessions = new Set(); private readonly rewriteConflictLeaseSessions = new Set(); private readonly turnReleaseWaiters = new Map void>>>(); + /** At most one goal action waits per session; a newer action replaces it. */ + private readonly pendingGoalTurnBySession = new Map(); + private readonly goalTurnWaiterBySession = new Map>(); // Serializes ownership mutations per session so prompt completion and steer // application never race the boundary. No global concurrency cap (Infinity): // this is pure per-session serialization, matching the old hand-rolled lock. @@ -1163,6 +1202,168 @@ export class SessionExecutionService { return Array.from(bySession, ([sessionId, turnId]) => ({ sessionId, turnId })); } + /** + * Run a goal action against a session. + * + * Status-only actions go out-of-band when the agent advertises that: an + * active goal holds this session's only prompt slot open across the agent's + * own continuations, so a pause that waited for a free slot would wait for + * the thing it is trying to stop. Everything else runs inside a Lody-owned + * turn, and if a turn is already running the action waits for that turn + * instead of being dropped — the caller gets `queued`, not a dead button. + */ + async controlSessionGoal(options: { + sessionId: SessionId; + action: SessionGoalAction; + objective?: string; + userId: string; + userName: string; + userEmail: string; + }): Promise { + const { sessionId, action } = options; + const respond = ( + disposition: SessionGoalResponse['disposition'], + error?: string + ): SessionGoalResponse => ({ + type: 'session/goal_response', + sessionId, + action, + accepted: + disposition === 'applied' || disposition === 'turn_started' || disposition === 'queued', + disposition, + ...(error ? { error } : {}), + }); + + const agentClient = this.deps.sessionManager.getSession(sessionId)?.agentClient; + if (agentClient) { + const transport = agentClient.resolveGoalActionTransport(action); + if (transport === null) { + return respond('unsupported', `Agent does not support goal ${action}`); + } + if (transport === 'request') { + try { + await agentClient.controlGoal(action); + return respond('applied'); + } catch (error) { + this.deps.logger.warn( + `[${sessionId}] Goal ${action} control request failed: ${formatErrorMessage(error)}` + ); + return respond('error', formatErrorMessage(error)); + } + } + } + + // No live agent, or an action that needs a turn: the turn boots the session + // when necessary and lets the agent client pick its transport at prompt time. + const control: GoalPromptControl = { + action, + ...(options.objective ? { objective: options.objective } : {}), + }; + const request: SessionGoalTurnRequest = { + sessionId, + control, + userId: options.userId, + userName: options.userName, + userEmail: options.userEmail, + }; + if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { + this.queueGoalTurn(request); + return respond('queued'); + } + try { + await this.startGoalTurn(request); + return respond('turn_started'); + } catch (error) { + this.deps.logger.warn( + `[${sessionId}] Goal ${action} turn failed to start: ${formatErrorMessage(error)}` + ); + return respond('error', formatErrorMessage(error)); + } + } + + /** + * Hold one goal action per session until the running turn releases the prompt. + * + * A newer action replaces an older one: the user's latest intent is the only + * one worth running, and running a stale pause after a resume would undo it. + */ + private queueGoalTurn(request: SessionGoalTurnRequest): void { + const { sessionId } = request; + this.pendingGoalTurnBySession.set(sessionId, request); + if (this.goalTurnWaiterBySession.has(sessionId)) { + return; + } + const waiter = (async () => { + // Bounded: a session the user keeps chatting in must not pin this loop. + for (let attempt = 0; attempt < GOAL_TURN_QUEUE_MAX_WAITS; attempt += 1) { + const snapshot = this.getExecutionSnapshot(sessionId); + if (!snapshot.hasActiveTurn || !snapshot.activeTurnId) { + break; + } + await this.waitForTurnRelease(sessionId, snapshot.activeTurnId); + } + const pending = this.pendingGoalTurnBySession.get(sessionId); + this.pendingGoalTurnBySession.delete(sessionId); + this.goalTurnWaiterBySession.delete(sessionId); + if (!pending) return; + if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { + this.deps.logger.warn( + `[${sessionId}] Dropping queued goal ${pending.control.action}: session stayed busy` + ); + return; + } + await this.startGoalTurn(pending); + })().catch((error: unknown) => { + this.pendingGoalTurnBySession.delete(sessionId); + this.goalTurnWaiterBySession.delete(sessionId); + this.deps.logger.error( + `[${sessionId}] Queued goal turn failed: ${formatErrorMessage(error)}` + ); + }); + this.goalTurnWaiterBySession.set(sessionId, waiter); + } + + private async startGoalTurn(request: SessionGoalTurnRequest): Promise { + const { sessionId, control } = request; + const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); + const meta = await sessionDoc.getMetaState(); + if (!meta) { + throw new Error(`Session ${sessionId} has no metadata`); + } + if (meta.isArchived) { + throw new Error(`Session ${sessionId} is archived`); + } + if (!meta.cliType || !meta.agentType) { + throw new Error(`Session ${sessionId} has no agent configuration`); + } + const resumeAcpSessionId = resolveDispatchAcpSessionId(meta); + await this.continueSession( + { + type: 'session/chat', + sessionId, + machineId: this.deps.machineId, + workspaceId: this.deps.workspaceId, + ...(meta.project ? { project: meta.project } : {}), + acpSessionConfig: { + // Fallback blocks only: the agent replaces them when the action + // schedules its own continuation. The run configuration is + // deliberately absent so a goal turn cannot change model or mode. + prompt: GOAL_CONTINUATION_PROMPT_TEXT, + cliType: meta.cliType, + agentType: meta.agentType, + ...(resumeAcpSessionId ? { resume: resumeAcpSessionId } : {}), + }, + // A goal turn owns an assistant entry but no user message, so this id + // is provenance only and never becomes a dispatch pointer. + userTurnId: `goal:${control.action}:${randomUUID()}`, + userId: request.userId, + userName: request.userName, + userEmail: request.userEmail, + }, + { dispatchSource: 'goal', goalControl: control } + ); + } + async steerSession(options: { sessionId: SessionId; expectedTurnId: string; @@ -1601,7 +1802,7 @@ export class SessionExecutionService { private createTurnRuntime( options: Pick< VisibleSessionTurnOptions, - 'sessionId' | 'session' | 'userTurnId' | 'invocation' | 'onTurnSettled' + 'sessionId' | 'session' | 'userTurnId' | 'invocation' | 'onTurnSettled' | 'goalControl' > & { turnId: string } ): TurnRuntimeState { return { @@ -1609,6 +1810,7 @@ export class SessionExecutionService { turnId: options.turnId, userTurnId: options.userTurnId, invocation: options.invocation, + goalControl: options.goalControl, session: options.session, promptStarted: false, promptInFlight: false, @@ -2892,6 +3094,9 @@ export class SessionExecutionService { turnId: runtime.turnId, promptPromise: agentClient.prompt(acpSessionId, promptBlocks, { signal, + ...(runtime.goalControl + ? { goalControl: runtime.goalControl } + : {}), }), }); await self.awaitPromptHandoffTail(runtime, initialRun); @@ -3464,8 +3669,9 @@ export class SessionExecutionService { prepareOptions?: { sessionDoc?: SessionDocument } ): Promise { const { sessionId, acpSessionConfig, userId, userName, userEmail, userTurnId } = message; - const executionUserTurnId = - dispatchOptions?.dispatchSource === 'delivery' ? undefined : userTurnId; + const executionUserTurnId = isSystemCausedDispatch(dispatchOptions?.dispatchSource) + ? undefined + : userTurnId; const sessionDoc = prepareOptions?.sessionDoc ?? (await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId)); @@ -4187,6 +4393,7 @@ export class SessionExecutionService { ...(dispatchOptions?.dispatchSource === 'delivery' ? { assistantEntryParentTurnId: userTurnId } : {}), + ...(dispatchOptions?.goalControl ? { goalControl: dispatchOptions.goalControl } : {}), ...(dispatchOptions?.onTurnStarted ? { onTurnStarted: dispatchOptions.onTurnStarted } : {}), ...(dispatchOptions?.onTurnSettled ? { onTurnSettled: dispatchOptions.onTurnSettled } : {}), ...(dispatchOptions?.dispatchSource @@ -5060,7 +5267,8 @@ export class SessionExecutionService { capabilities.sessionFork, sourceVersion, capabilities.modelReasoningEfforts, - capabilities.acknowledgedSteer + capabilities.acknowledgedSteer, + capabilities.goalActions ); })().catch((error: unknown) => { this.deps.logger.debug( @@ -5369,6 +5577,7 @@ export class SessionExecutionService { availableCommands, sessionFork, acknowledgedSteer, + goalActions, modelReasoningEfforts, capabilitySourceVersion, } = await this.deps.fetchAcpCapabilities( @@ -5409,6 +5618,7 @@ export class SessionExecutionService { }), modelReasoningEfforts, acknowledgedSteer, + goalActions, { signal: options.signal } ); diff --git a/apps/cli/src/session/session.ts b/apps/cli/src/session/session.ts index 9adb4e5ec..55fc11acf 100644 --- a/apps/cli/src/session/session.ts +++ b/apps/cli/src/session/session.ts @@ -665,6 +665,7 @@ export class Session extends EventEmitter implements ISession { acpCapabilities = normalizeAcpSessionCapabilities(started.sessionResponse, { sessionFork: started.client.supportsSessionFork(), acknowledgedSteer: started.client.supportsAcknowledgedSteer(), + goalActions: started.client.getGoalCapability()?.actions.slice(), agent: { cliType: this.config.agentCliType, agentType: this.config.agentType }, }); } catch (error) { diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index d100f3b79..9f7b41cf1 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -2446,7 +2446,9 @@ describe('SessionExecutionService', () => { // Per-model reasoning efforts: absent for this agent, which publishes no // legacy `model[effort]` combination list. undefined, - true + true, + // Goal actions: the fixture client advertises no goal extension. + undefined ) ); }); @@ -6169,6 +6171,8 @@ describe('SessionExecutionService', () => { 'registry:deepseek:unknown', capability.modelReasoningEfforts, true, + // Goal actions: this runtime advertises no goal extension. + undefined, { signal: expect.any(AbortSignal) } ); expect(result).toEqual( @@ -6525,3 +6529,140 @@ describe('SessionExecutionService', () => { expect(fetchAcpCapabilities).toHaveBeenCalledTimes(2); }); }); + +describe('SessionExecutionService goal control', () => { + const goalSessionId = 'session-goal' as SessionId; + + const createGoalService = ( + overrides: { + transport?: 'request' | 'promptMeta' | 'slashCommand' | null; + hasSession?: boolean; + } = {} + ) => { + const controlGoal = vi.fn(async () => {}); + const agentClient = { + isCreated: vi.fn(() => true), + resolveGoalActionTransport: vi.fn(() => + 'transport' in overrides ? overrides.transport : 'request' + ), + controlGoal, + currentModel: undefined, + }; + const getSession = vi.fn(() => + overrides.hasSession === false ? null : { agentClient, acpSessionId: 'acp-goal' } + ); + const deps = createBaseDeps({ + sessionManager: { + getSession, + getPendingSession: vi.fn(() => null), + } as unknown as SessionManager, + workspaceDocument: { + repo: { upsertDocMeta: vi.fn(async () => {}) }, + getOrCreateSessionDoc: vi.fn(async () => ({ + getMetaState: vi.fn(async () => ({ + id: goalSessionId, + cliType: 'builtin', + agentType: 'codex', + acpSessionId: 'acp-goal', + })), + updateHistory: vi.fn(async () => {}), + })), + } as unknown as LoroDocumentManager, + }); + const service = new SessionExecutionService(deps); + return { service, controlGoal, agentClient }; + }; + + const goalArgs = { + sessionId: goalSessionId, + userId: 'owner-user', + userName: 'Owner', + userEmail: 'owner@example.com', + } as const; + + it('pauses out-of-band without opening a turn', async () => { + const { service, controlGoal } = createGoalService({ transport: 'request' }); + const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); + + expect(response).toMatchObject({ accepted: true, disposition: 'applied', action: 'pause' }); + expect(controlGoal).toHaveBeenCalledWith('pause'); + // The goal's own prompt owns the session's only turn slot; a pause that + // needed a free slot could never reach the goal it is stopping. + expect(continueSession).not.toHaveBeenCalled(); + }); + + it('refuses an action the agent never advertised', async () => { + const { service } = createGoalService({ transport: null }); + const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + expect(response).toMatchObject({ accepted: false, disposition: 'unsupported' }); + expect(continueSession).not.toHaveBeenCalled(); + }); + + it('starts a Lody-owned turn for an action that resumes work', async () => { + const { service } = createGoalService({ transport: 'promptMeta' }); + const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + expect(response).toMatchObject({ accepted: true, disposition: 'turn_started' }); + expect(continueSession).toHaveBeenCalledTimes(1); + const [request, options] = continueSession.mock.calls[0]!; + expect(options).toMatchObject({ + dispatchSource: 'goal', + goalControl: { action: 'resume' }, + }); + // No user message: the turn exists to carry the goal, not to say anything. + expect(request.userTurnId.startsWith('goal:resume:')).toBe(true); + expect(request.acpSessionConfig.modelId).toBeUndefined(); + expect(request.acpSessionConfig.modeId).toBeUndefined(); + }); + + it('waits for the running turn to release instead of dropping the resume', async () => { + const { service } = createGoalService({ transport: 'promptMeta' }); + const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const internals = service as unknown as { + currentTurnBySession: Map; + clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; + goalTurnWaiterBySession: Map>; + }; + internals.currentTurnBySession.set(goalSessionId, 'draining-turn'); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); + expect(continueSession).not.toHaveBeenCalled(); + + internals.clearCurrentTurn(goalSessionId, 'draining-turn'); + await internals.goalTurnWaiterBySession.get(goalSessionId); + + expect(continueSession).toHaveBeenCalledTimes(1); + expect(continueSession.mock.calls[0]![1]).toMatchObject({ dispatchSource: 'goal' }); + }); + + it('keeps only the newest queued action so a stale pause cannot undo a resume', async () => { + const { service } = createGoalService({ transport: 'promptMeta' }); + const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const internals = service as unknown as { + currentTurnBySession: Map; + clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; + goalTurnWaiterBySession: Map>; + }; + internals.currentTurnBySession.set(goalSessionId, 'draining-turn'); + + await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + internals.clearCurrentTurn(goalSessionId, 'draining-turn'); + await internals.goalTurnWaiterBySession.get(goalSessionId); + + expect(continueSession).toHaveBeenCalledTimes(1); + expect(continueSession.mock.calls[0]![1]).toMatchObject({ + goalControl: { action: 'resume' }, + }); + }); +}); diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index f9dbc8c0e..1a35bc0e8 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit f9dbc8c0e98bc11ad13e0f487ecb2b6e0672cfea +Subproject commit 1a35bc0e8526c484aa15ec0bbfd3836daae2219b diff --git a/packages/acp-extension-core b/packages/acp-extension-core index 1aa24313e..9e7503c0c 160000 --- a/packages/acp-extension-core +++ b/packages/acp-extension-core @@ -1 +1 @@ -Subproject commit 1aa24313e6deb24f0f80fe6ff9196a4c78fe94df +Subproject commit 9e7503c0ca69140fa8ae1d0cd01dc4acfef1e169 diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 41f930d73..7054b43d9 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -17,6 +17,8 @@ import type { SessionPrepareCancelResponse, SessionPrepareResponse, SessionSteerResponse, + SessionGoalAction, + SessionGoalResponse, SessionDocMeta, SessionTurnInputConfig, SessionId, @@ -299,6 +301,17 @@ export type WorkspaceRuntime = { }, options?: { timeoutMs?: number } ) => Promise; + requestSessionGoal: ( + machineId: MachineId, + args: { + sessionId: SessionId; + action: SessionGoalAction; + objective?: string; + userId: string; + timestamp: string; + }, + options?: { timeoutMs?: number } + ) => Promise; requestSessionTerminate: ( machineId: MachineId, sessionId: SessionId, diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index 5ddcdf5fd..62af7c184 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -209,9 +209,8 @@ import { shouldDisableSessionInfoBarGitHubActionForHydration, } from './session-info-action-state'; import { - canPauseGoalThroughPromptBridge, - getPromptBridgeGoalCommands, - GOAL_PROMPT_DISPATCH_OPTIONS, + getSessionGoalCommands, + GOAL_COMMAND_PENDING_TIMEOUT_MS, isSessionPromptBusy, } from './session-goal-control'; import { resolveSessionMessageSubmitRoute } from './session-message-submit-route'; @@ -2374,6 +2373,7 @@ export const SessionChatInterface = memo( markSessionRead, requestSessionCancel, requestSessionDispatch, + requestSessionGoal, requestSessionSteer, touchSessionActivity, transferSessionOwner, @@ -2698,11 +2698,14 @@ export const SessionChatInterface = memo( [legacySession.latestGoal, session.dismissedGoalThreadId, sessionHistory] ); const isGoalActive = isSessionGoalActive(latestGoal); - // The existing prompt bridge is Codex-specific. Other providers may publish - // neutral goal snapshots, but their advertised `_session/goal` extension is - // not yet routed through Lody's session control plane, so keep them read-only. - const goalCommands = getPromptBridgeGoalCommands(session.agentType); - const canPauseGoal = canPauseGoalThroughPromptBridge(session.agentType); + // Goal control is an ACP extension, so the runtime's advertised actions + // decide which buttons exist. A runtime with no goal extension stays + // read-only rather than being guessed at from the agent's name. + const goalCapability = session.agentConfigId + ? sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(session.agentConfigId)] + : undefined; + const goalCommands = useMemo(() => getSessionGoalCommands(goalCapability), [goalCapability]); + const canPauseGoal = goalCommands.includes('pause'); useEffect(() => { if (!pendingGoalCommand) { @@ -2734,6 +2737,19 @@ export const SessionChatInterface = memo( } }, [latestGoal, pendingGoalCommand]); + useEffect(() => { + if (!pendingGoalCommand) { + return undefined; + } + // The agent's own goal snapshot is the completion signal, and a queued + // action waits for a running turn to drain. Stop waiting eventually so a + // command that never lands cannot leave every goal button disabled. + const timer = setTimeout(() => { + setPendingGoalCommand((current) => (current === pendingGoalCommand ? null : current)); + }, GOAL_COMMAND_PENDING_TIMEOUT_MS); + return () => clearTimeout(timer); + }, [pendingGoalCommand]); + const isSessionActive = liveSessionStatus != null; // CLI-reported presence is the fact source for "working now". The only // frontend-derived state is the dispatched-but-not-started window, read @@ -4135,20 +4151,24 @@ export const SessionChatInterface = memo( return false; } - directDispatchInFlightRef.current = false; - setInputActionState('ready'); if (options?.showPending !== false) { setPendingGoalCommand({ threadId: goal.threadId, command }); } try { - const accepted = await dispatchPrompt(`/goal ${command}`, GOAL_PROMPT_DISPATCH_OPTIONS); - if (!accepted) { - throw new Error('Goal command was not accepted for dispatch'); + const response = await requestSessionGoal(session.id, command, { + userId: currentUser?.id ?? session.userId, + machineId: session.machineId, + }); + if (!response?.accepted) { + throw new Error( + response?.error ?? `Goal command was ${response?.disposition ?? 'not delivered'}` + ); } captureSessionEvent('session/goal_command_dispatched', { command, goal_thread_id: goal.threadId, + disposition: response.disposition, }); return true; } catch (error) { @@ -4169,7 +4189,17 @@ export const SessionChatInterface = memo( return false; } }, - [captureSessionEvent, dispatchPrompt, goalCommands, latestGoal, t] + [ + captureSessionEvent, + currentUser?.id, + goalCommands, + latestGoal, + requestSessionGoal, + session.id, + session.machineId, + session.userId, + t, + ] ); const handleGoalCardCommand = useCallback( @@ -4999,6 +5029,8 @@ export const SessionChatInterface = memo( return; } + // Cancel first so Stop stays immediate; the pause that follows is an + // out-of-band control request and no longer waits for a free prompt slot. if (goalToPause) { await handleGoalCommand('pause', goalToPause, { showPending: false }); } diff --git a/packages/components/src/components/sessions/session-goal-control.ts b/packages/components/src/components/sessions/session-goal-control.ts index 9783f5be2..c9fa47c95 100644 --- a/packages/components/src/components/sessions/session-goal-control.ts +++ b/packages/components/src/components/sessions/session-goal-control.ts @@ -1,30 +1,43 @@ -import { SESSION_GOAL_COMMANDS, type SessionGoalCommand } from '@lody/shared'; +import { + SESSION_GOAL_COMMANDS, + type AcpCapabilityCacheEntry, + type SessionGoalCommand, +} from '@lody/shared'; const NO_GOAL_COMMANDS: readonly SessionGoalCommand[] = []; /** - * Commands supported by Lody's current `/goal …` prompt bridge. + * Goal commands the session's agent actually implements. * - * Provider-neutral goal snapshots are displayable for every ACP agent, but the - * prompt bridge itself is Codex-specific. Other providers remain read-only until - * Lody routes their advertised `_session/goal` extension method. + * Read from the runtime's advertised capability rather than the agent's name: + * goal control is an ACP extension, so any agent that advertises it gets the + * controls, and one that does not stays read-only. */ -export const getPromptBridgeGoalCommands = ( - agentType: string | null | undefined -): readonly SessionGoalCommand[] => - agentType === 'codex' ? SESSION_GOAL_COMMANDS : NO_GOAL_COMMANDS; +export const getSessionGoalCommands = ( + capability: Pick | undefined +): readonly SessionGoalCommand[] => { + const actions = capability?.goalActions; + if (!actions?.length) { + return NO_GOAL_COMMANDS; + } + const supported = SESSION_GOAL_COMMANDS.filter((command) => actions.includes(command)); + return supported.length > 0 ? supported : NO_GOAL_COMMANDS; +}; -export const canPauseGoalThroughPromptBridge = ( - agentType: string | null | undefined -): boolean => getPromptBridgeGoalCommands(agentType).includes('pause'); +export const canPauseSessionGoal = ( + capability: Pick | undefined +): boolean => getSessionGoalCommands(capability).includes('pause'); /** - * Slash `/goal …` commands must never route through steer/guide submit paths. - * Steer rejects slash input ("Slash commands cannot steer an active Codex turn") - * and Stop's `/goal pause` side-effect would wedge the session when queued - * message behavior is set to Steer. + * How long a goal command may sit in its pending state before the UI stops + * waiting. + * + * The command's real completion signal is the agent's own goal snapshot, and a + * queued action legitimately waits for a running turn to drain. Without a + * deadline a queued command would leave every goal button disabled forever, + * which is exactly the dead-end this control plane exists to remove. */ -export const GOAL_PROMPT_DISPATCH_OPTIONS = { forceDirect: true as const }; +export const GOAL_COMMAND_PENDING_TIMEOUT_MS = 60_000; export type SessionPromptActivity = { isDispatching: boolean; diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index 305b21628..8a15112ad 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -16,6 +16,8 @@ import type { SessionTurnInputConfig, MachineFlockKey, MachineFlockRow, + SessionGoalAction, + SessionGoalResponse, } from '@lody/shared'; import { buildMachineArchiveSessionCommand, @@ -367,6 +369,17 @@ export type SessionActions = { userTurnId: string, options?: { machineId?: MachineId | null } ) => Promise; + /** + * Run a goal action through the agent's control extension. + * + * Status-only actions reach a goal whose prompt is still open, which the chat + * path cannot do: that prompt is the session's only turn slot. + */ + requestSessionGoal: ( + sessionId: SessionId, + action: SessionGoalAction, + options?: { objective?: string; userId?: string; machineId?: MachineId | null } + ) => Promise; touchSessionActivity: (sessionId: SessionId) => Promise; updateSessionStatus: (sessionId: SessionId, status: SessionStatus) => Promise; updateSessionTitle: (sessionId: SessionId, title: string) => Promise; @@ -909,6 +922,36 @@ export function useSessionActions(): SessionActions { [runtime] ); + const requestSessionGoal = useCallback( + async ( + sessionId: SessionId, + action: SessionGoalAction, + options?: { objective?: string; userId?: string; machineId?: MachineId | null } + ): Promise => { + if (!runtime) { + throw new Error('Runtime not ready'); + } + const roomId = getSessionRoomId(sessionId); + const existing = await runtime.repo.getDocMeta(roomId); + const meta = isLoroRepoDocDeleted(existing) + ? undefined + : (existing?.meta as SessionMeta | undefined); + const machineId = options?.machineId ?? meta?.machineId ?? null; + const userId = options?.userId?.trim() || meta?.userId; + if (!machineId || !userId) { + return null; + } + return await runtime.requestSessionGoal(machineId, { + sessionId, + action, + ...(options?.objective ? { objective: options.objective } : {}), + userId, + timestamp: new Date().toISOString(), + }); + }, + [runtime] + ); + const requestSessionSteer = useCallback( async ( sessionId: SessionId, @@ -1435,6 +1478,7 @@ export function useSessionActions(): SessionActions { requestSessionDispatch, requestSessionCancel, requestSessionSteer, + requestSessionGoal, touchSessionActivity, updateSessionStatus, updateSessionTitle, diff --git a/packages/components/src/providers/create-workspace-runtime.ts b/packages/components/src/providers/create-workspace-runtime.ts index 1be55917f..6a42ff11e 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1657,6 +1657,7 @@ export async function createWorkspaceRuntime(deps: RuntimeDeps): Promise => { + const failure = (error: string): SessionGoalResponse => ({ + type: 'session/goal_response', + sessionId: args.sessionId, + action: args.action, + accepted: false, + disposition: 'error', + error, + }); + try { + if (await canUseLocalMachineRpc(machineId)) { + const response = await getLocalMachineRpcSender()?.({ + machineId, + workspaceId, + method: 'session/goal', + params: args, + timeoutMs: options?.timeoutMs ?? 10_000, + }); + if (response && !response.ok) { + return failure(response.error); + } + if (response?.ok) return response.result as SessionGoalResponse; + } + return await ( + await getMachineRpcClient(machineId) + ).requestSessionGoal({ + ...args, + timeoutMs: options?.timeoutMs ?? 10_000, + }); + } catch (error) { + return failure(error instanceof Error ? error.message : String(error)); + } + }; + const requestSessionFork = async ( machineId: MachineId, args: SessionForkSpec, @@ -1119,6 +1165,7 @@ export function createWorkspaceMachineRpcFacade(deps: WorkspaceMachineRpcFacadeD return { requestSessionCancel, requestSessionSteer, + requestSessionGoal, requestSessionTerminate, requestSessionFork, requestSessionEditAndResend, diff --git a/packages/components/tests/session-goal-control.test.ts b/packages/components/tests/session-goal-control.test.ts index 009835876..6ac52a7ec 100644 --- a/packages/components/tests/session-goal-control.test.ts +++ b/packages/components/tests/session-goal-control.test.ts @@ -1,25 +1,32 @@ import { describe, expect, it } from 'vitest'; import { - canPauseGoalThroughPromptBridge, - getPromptBridgeGoalCommands, - GOAL_PROMPT_DISPATCH_OPTIONS, + canPauseSessionGoal, + getSessionGoalCommands, isSessionPromptBusy, } from '../src/components/sessions/session-goal-control'; -describe('session goal prompt bridge', () => { - it('keeps provider-neutral Claude goals read-only', () => { - expect(getPromptBridgeGoalCommands('claude')).toEqual([]); - expect(canPauseGoalThroughPromptBridge('claude')).toBe(false); +describe('session goal control availability', () => { + it('keeps goals read-only for a runtime that advertises no goal actions', () => { + expect(getSessionGoalCommands(undefined)).toEqual([]); + expect(getSessionGoalCommands({ goalActions: [] })).toEqual([]); + expect(canPauseSessionGoal(undefined)).toBe(false); }); - it('keeps the existing Codex pause, resume, and clear controls', () => { - expect(getPromptBridgeGoalCommands('codex')).toEqual(['pause', 'resume', 'clear']); - expect(canPauseGoalThroughPromptBridge('codex')).toBe(true); + it('offers the commands the runtime advertised, whatever the agent is', () => { + const capability = { goalActions: ['set', 'pause', 'resume', 'clear'] as const }; + expect(getSessionGoalCommands({ goalActions: [...capability.goalActions] })).toEqual([ + 'pause', + 'resume', + 'clear', + ]); + expect(canPauseSessionGoal({ goalActions: [...capability.goalActions] })).toBe(true); }); - it('defaults unknown ACP providers to read-only goals', () => { - expect(getPromptBridgeGoalCommands('custom-agent')).toEqual([]); - expect(canPauseGoalThroughPromptBridge(undefined)).toBe(false); + it('offers only the subset a partial runtime advertised', () => { + // `set` has no button of its own, and an unadvertised action must never get + // one: pressing it would fail at the agent. + expect(getSessionGoalCommands({ goalActions: ['set', 'clear'] })).toEqual(['clear']); + expect(canPauseSessionGoal({ goalActions: ['set', 'clear'] })).toBe(false); }); it('keeps a quiescent session direct-dispatchable while its goal remains active', () => { @@ -48,8 +55,4 @@ describe('session goal prompt bridge', () => { }) ).toBe(true); }); - - it('forces direct dispatch for slash goal commands so steer cannot reject them', () => { - expect(GOAL_PROMPT_DISPATCH_OPTIONS).toEqual({ forceDirect: true }); - }); }); diff --git a/packages/loro-streams-rpc/src/machine-rpc-server.ts b/packages/loro-streams-rpc/src/machine-rpc-server.ts index dc73401d9..5579d0dea 100644 --- a/packages/loro-streams-rpc/src/machine-rpc-server.ts +++ b/packages/loro-streams-rpc/src/machine-rpc-server.ts @@ -45,6 +45,8 @@ import type { SessionForkResponse, SessionForkSpec, SessionSteerResponse, + SessionGoalAction, + SessionGoalResponse, SessionId, SessionPreviewCreateResponse, SessionPreviewRevokeResponse, @@ -117,6 +119,7 @@ const CONTROL_METHODS: ReadonlySet = new Set([ 'session/cancel', 'session/live-status', 'session/steer', + 'session/goal', 'session/terminate', 'session/dispatch-turn', 'session/prepare', @@ -354,6 +357,12 @@ type RpcServerDeps = { timestamp: string; inputConfig: SessionTurnInputConfig; }) => Promise; + controlSessionGoal?: (args: { + sessionId: SessionId; + action: SessionGoalAction; + objective?: string; + userId: string; + }) => Promise; terminateSession?: (args: { sessionId: SessionId }) => Promise; forkSession?: (args: SessionForkSpec) => Promise; editAndResendSession?: ( @@ -1096,6 +1105,23 @@ export class LoroStreamsMachineRpcServer { await this.appendResultResponse(request.replyTo, request.id, request.method, response); return; } + case 'session/goal': { + if (!this.deps.controlSessionGoal) { + await this.appendErrorResponse(request.replyTo, request.id, request.method, { + code: LORO_STREAMS_RPC_ERROR_CODES.methodUnavailable, + message: 'Session goal control is not available on this machine.', + }); + return; + } + const response = await this.deps.controlSessionGoal({ + sessionId: request.params.sessionId as SessionId, + action: request.params.action, + ...(request.params.objective ? { objective: request.params.objective } : {}), + userId: request.params.userId, + }); + await this.appendResultResponse(request.replyTo, request.id, request.method, response); + return; + } case 'session/terminate': { if (!this.deps.terminateSession) { await this.appendErrorResponse(request.replyTo, request.id, request.method, { @@ -1588,6 +1614,7 @@ export class LoroStreamsMachineRpcServer { | SessionCancelResponse | LoroSessionLiveStatusRpcResponse | SessionSteerResponse + | SessionGoalResponse | SessionTerminateResponse | SessionForkResponse | SessionEditAndResendResponse diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index da9def8c8..ed651f34e 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -45,6 +45,8 @@ import type { MachineStatusResponse, MachineUpgradeResponse, SessionCancelResponse, + SessionGoalAction, + SessionGoalResponse, SessionPreparationCancelSpec, SessionPreparationSpec, SessionPrepareCancelResponse, @@ -106,6 +108,8 @@ import { sessionEditAndResendFailure, sessionForkFailure, SessionSteerResponseSchema, + SessionGoalResponseSchema, + SESSION_GOAL_ACTIONS, SessionPreviewCreateResponseSchema, SessionPreviewRevokeResponseSchema, } from '@lody/shared'; @@ -189,6 +193,7 @@ export const LoroStreamsRpcMethodSchema = z.enum([ 'session/cancel', 'session/live-status', 'session/steer', + 'session/goal', 'session/terminate', 'session/fork', 'session/edit-and-resend', @@ -440,6 +445,19 @@ export const LoroSessionLiveStatusRpcRequestSchema = BaseRpcRequestSchema.extend .strict(), }).strict(); +export const LoroSessionGoalRpcRequestSchema = BaseRpcRequestSchema.extend({ + method: z.literal('session/goal'), + params: z + .object({ + sessionId: z.string().trim().min(1), + action: z.enum(SESSION_GOAL_ACTIONS), + objective: z.string().trim().min(1).optional(), + userId: z.string().trim().min(1), + timestamp: z.string().trim().min(1), + }) + .strict(), +}).strict(); + export const LoroSessionSteerRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/steer'), params: z @@ -582,6 +600,7 @@ export const LoroStreamsRpcRequestSchema = z.discriminatedUnion('method', [ LoroSessionCancelRpcRequestSchema, LoroSessionLiveStatusRpcRequestSchema, LoroSessionSteerRpcRequestSchema, + LoroSessionGoalRpcRequestSchema, LoroSessionTerminateRpcRequestSchema, LoroSessionForkRpcRequestSchema, LoroSessionEditAndResendRpcRequestSchema, @@ -1424,6 +1443,7 @@ export type LoroMachineRpcResult = | SessionCancelResponse | LoroSessionLiveStatusRpcResponse | SessionSteerResponse + | SessionGoalResponse | SessionTerminateResponse | SessionForkResponse | SessionEditAndResendResponse @@ -1451,6 +1471,7 @@ const toLegacyRpcErrorResponse = ( forkContext?: { sourceSessionId: string; targetSessionId: string }, editAndResendContext?: { sessionId: string; replacementUserTurnId: string }, steerContext?: { sessionId: string; userTurnId: string }, + goalContext?: { sessionId: string; action: SessionGoalAction }, previewContext?: { sessionId: string }, localProjectContext?: { workspaceId?: string; @@ -1603,6 +1624,17 @@ const toLegacyRpcErrorResponse = ( ); } + if (method === 'session/goal') { + return { + type: 'session/goal_response', + sessionId: (goalContext?.sessionId ?? '') as SessionGoalResponse['sessionId'], + action: goalContext?.action ?? 'pause', + accepted: false, + disposition: 'error', + error: `${error.code}: ${error.message}`, + }; + } + if (method === 'session/steer') { return { type: 'session/steer_response', @@ -1788,6 +1820,10 @@ const parseRpcSuccessResult = async ( const parsed = SessionSteerResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionSteerResponse) : null; } + if (response.method === 'session/goal') { + const parsed = SessionGoalResponseSchema.safeParse(response.result); + return parsed.success ? (parsed.data as SessionGoalResponse) : null; + } if (response.method === 'session/terminate') { const parsed = SessionTerminateResponseSchema.safeParse(response.result); return parsed.success ? (parsed.data as SessionTerminateResponse) : null; @@ -1861,6 +1897,7 @@ export type LoroStreamsRpcPendingRegistration = { forkContext?: { sourceSessionId: string; targetSessionId: string }; editAndResendContext?: { sessionId: string; replacementUserTurnId: string }; steerContext?: { sessionId: string; userTurnId: string }; + goalContext?: { sessionId: string; action: SessionGoalAction }; previewContext?: { sessionId: string }; localProjectContext?: { workspaceId?: string; @@ -2244,6 +2281,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.forkContext, finalPending.editAndResendContext, finalPending.steerContext, + finalPending.goalContext, finalPending.previewContext, finalPending.localProjectContext, finalPending.dispatchContext, @@ -2276,6 +2314,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.forkContext, finalPending.editAndResendContext, finalPending.steerContext, + finalPending.goalContext, finalPending.previewContext, finalPending.localProjectContext, finalPending.dispatchContext, @@ -2651,6 +2690,27 @@ export class LoroStreamsMachineRpcClient { })) as SessionSteerResponse | null; } + async requestSessionGoal(options: { + sessionId: string; + action: SessionGoalAction; + objective?: string; + userId: string; + timestamp: string; + timeoutMs?: number; + }): Promise { + return (await this.sendRequest({ + method: 'session/goal', + timeoutMs: options.timeoutMs ?? 10_000, + params: { + sessionId: options.sessionId, + action: options.action, + ...(options.objective ? { objective: options.objective } : {}), + userId: options.userId, + timestamp: options.timestamp, + }, + })) as SessionGoalResponse | null; + } + async requestSessionTerminate(options: { sessionId: string; timeoutMs?: number; @@ -3120,6 +3180,17 @@ export class LoroStreamsMachineRpcClient { inputConfig: SessionTurnInputConfig; }; } + | { + method: 'session/goal'; + timeoutMs: number; + params: { + sessionId: string; + action: SessionGoalAction; + objective?: string; + userId: string; + timestamp: string; + }; + } | { method: 'session/terminate'; timeoutMs: number; @@ -3332,6 +3403,10 @@ export class LoroStreamsMachineRpcClient { args.method === 'session/steer' ? { sessionId: args.params.sessionId, userTurnId: args.params.userTurnId } : undefined, + goalContext: + args.method === 'session/goal' + ? { sessionId: args.params.sessionId, action: args.params.action } + : undefined, dispatchContext: args.method === 'session/dispatch-turn' ? { sessionId: args.params.sessionId, userTurnId: args.params.userTurnId } @@ -3440,6 +3515,9 @@ export class LoroStreamsMachineRpcClient { case 'session/steer': request = { ...envelope, method: args.method, params: args.params }; break; + case 'session/goal': + request = { ...envelope, method: args.method, params: args.params }; + break; case 'session/terminate': request = { ...envelope, method: args.method, params: args.params }; break; @@ -3628,6 +3706,7 @@ export class LoroStreamsMachineRpcClient { pending.forkContext, pending.editAndResendContext, pending.steerContext, + pending.goalContext, pending.previewContext, pending.localProjectContext, pending.dispatchContext, diff --git a/packages/shared/AGENTS.md b/packages/shared/AGENTS.md index 1dd3d63e2..4985c7732 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -18,6 +18,18 @@ per-turn MCP selection, or Role-based session creation and dispatch. operation, adapting only fields with known incompatible semantics; runtime-override source matching remains a separate applicability gate. +## Session goal control + +- A goal action's transport follows what it does, not what the agent is. Status-only + actions (`pause`, `clear`) use the `_lody/session/goal` request and must reach a + goal whose prompt is still open; actions that start work (`set`, `resume`) run + inside a Lody-owned prompt carrying `_meta.lody.goalControl`, because every unit of + agent work needs a conversation entry to be attributed to. Never start goal work + from the request path. +- Offer only the actions the runtime advertised in its ACP capability, never a + provider name check. A goal turn carries no run configuration, so resuming cannot + change model or mode. Behavior: [goal control Spec](../../specs/session-goal-control.md). + ## Workspace MCP and Agent Roles - Workspace MCP has exactly two durable layers: catalog entries in the workspace Flock diff --git a/packages/shared/src/ai.ts b/packages/shared/src/ai.ts index 90d9dab28..ef47851e9 100644 --- a/packages/shared/src/ai.ts +++ b/packages/shared/src/ai.ts @@ -7,6 +7,7 @@ import { } from '@agentclientprotocol/sdk'; import type { ToolCallContent as AcpToolCallContent, SessionMode } from '@agentclientprotocol/sdk'; import type { PermissionOutcome } from './message'; +import type { SessionGoalAction } from './goal'; import type { AgentConfigId, AgentRoleId, McpServerId, SessionId } from './ids'; import type { MessageTextSpan } from './message-text-spans'; import type { MinimalVisualAnnotationAnchor } from './visual-annotation-types'; @@ -281,7 +282,7 @@ export type AcpCommandSummary = { // Codex-only carry a bogus ladder for every agent that spells other variants // with the same brackets — a Claude probe stored `{ opus: ['1m'] }` — and the // per-model effort picker would rebuild that model's ladder from it. -export const ACP_CAPABILITY_CACHE_VERSION = 7; +export const ACP_CAPABILITY_CACHE_VERSION = 8; export type AcpCapabilityAuthority = 'unavailable' | 'provisional' | 'authoritative'; @@ -315,6 +316,12 @@ export type AcpCapabilityCacheEntry = { sessionFork?: boolean; /** True only when the runtime advertised Lody's acknowledged steering extension. */ acknowledgedSteer?: boolean; + /** + * Goal actions the runtime advertised, on any transport. Absent means the + * runtime has no goal extension, which is what keeps the goal controls hidden + * instead of guessing from the agent's name. + */ + goalActions?: SessionGoalAction[]; /** True when this Lody machine supports durable asynchronous forks into a new worktree. */ sessionForkWorktree?: boolean; fetchedAt: number; diff --git a/packages/shared/src/goal.ts b/packages/shared/src/goal.ts index 59563dafc..99268b0b1 100644 --- a/packages/shared/src/goal.ts +++ b/packages/shared/src/goal.ts @@ -12,6 +12,15 @@ export const SESSION_GOAL_COMMANDS = ['pause', 'resume', 'clear'] as const; export type SessionGoalCommand = (typeof SESSION_GOAL_COMMANDS)[number]; +/** Every goal action Lody can ask an agent to perform, including `set`. */ +export const SESSION_GOAL_ACTIONS = ['set', 'pause', 'resume', 'clear'] as const; + +export type SessionGoalAction = (typeof SESSION_GOAL_ACTIONS)[number]; + +/** Actions that never start work, so they can be delivered out-of-band. */ +export const isStatusOnlySessionGoalAction = (action: SessionGoalAction): boolean => + action === 'pause' || action === 'clear'; + export const sanitizeLodyInternalInstructions = (text: string): string => { const markerIndex = LODY_INTERNAL_PROMPT_MARKERS.reduce((earliest, marker) => { const index = text.indexOf(marker); diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index ab82fbfe2..84471d565 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -1,5 +1,6 @@ import { LocalFileResolutionSchema } from './local-file-preview'; import { z } from 'zod'; +import { SESSION_GOAL_ACTIONS } from './goal'; import { CodeCollabV2ErrorSchema, CodeCollabV2FileIndexRequestSchema, @@ -37,6 +38,7 @@ import { SessionPreviewEndpointReleaseResponseSchema, PreviewTargetSchema, SessionSteerResponseSchema, + SessionGoalResponseSchema, SessionTerminateResponseSchema, } from './message-schemas'; @@ -203,6 +205,18 @@ export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ }) .strict(), }).strict(), + BaseLocalMachineRpcRequestSchema.extend({ + method: z.literal('session/goal'), + params: z + .object({ + sessionId: SessionIdSchema, + action: z.enum(SESSION_GOAL_ACTIONS), + objective: z.string().trim().min(1).optional(), + userId: z.string().trim().min(1), + timestamp: z.string().trim().min(1), + }) + .strict(), + }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('session/preview-endpoint-acquire'), params: z @@ -258,6 +272,7 @@ export const LocalMachineRpcResultSchema = z.union([ SessionPreviewEndpointAcquireResponseSchema, SessionPreviewEndpointReleaseResponseSchema, SessionSteerResponseSchema, + SessionGoalResponseSchema, SessionTerminateResponseSchema, ]); export type LocalMachineRpcResult = z.infer; diff --git a/packages/shared/src/message-schemas.ts b/packages/shared/src/message-schemas.ts index d791a8c21..cfbb70e0c 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -9,6 +9,7 @@ import { type ACPSessionId, type SessionTurnInputConfig, } from './ai'; +import { SESSION_GOAL_ACTIONS } from './goal'; import type { AgentRoleId, SessionId } from './ids'; import { MAX_MESSAGE_TEXT_SPAN_MARK_LENGTH, MESSAGE_TEXT_SPAN_KINDS } from './message-text-spans'; import { RpcSecretPublicKeySchema } from './rpc-secret'; @@ -684,6 +685,17 @@ export const SessionSteerResponseSchema = z }) .strict(); +export const SessionGoalResponseSchema = z + .object({ + type: z.literal('session/goal_response'), + sessionId: SessionIdSchema, + action: z.enum(SESSION_GOAL_ACTIONS), + accepted: z.boolean(), + disposition: z.enum(['applied', 'turn_started', 'queued', 'unsupported', 'error']), + error: z.string().optional(), + }) + .strict(); + export const SessionTerminateResponseSchema = z .object({ type: z.literal('session/terminate_response'), @@ -1226,6 +1238,7 @@ const AcpCapabilityCacheEntrySchema = z .optional(), sessionFork: z.boolean().optional(), acknowledgedSteer: z.boolean().optional(), + goalActions: z.array(z.enum(SESSION_GOAL_ACTIONS)).optional(), sessionForkWorktree: z.boolean().optional(), fetchedAt: z.number(), }) @@ -2022,6 +2035,7 @@ export const LocalSessionControlResponseSchema = z.discriminatedUnion('type', [ SessionChatResponseSchema, SessionCancelResponseSchema, SessionSteerResponseSchema, + SessionGoalResponseSchema, MachineStatusResponseSchema, MachinePingResponseSchema, MachineRestartResponseSchema, diff --git a/packages/shared/src/message.ts b/packages/shared/src/message.ts index fb51f3042..ee9c370f4 100644 --- a/packages/shared/src/message.ts +++ b/packages/shared/src/message.ts @@ -17,6 +17,7 @@ import type { SessionFilePayload, SessionTurnInputConfig, AcpCapabilityCacheEntry, + SessionGoalAction, } from '.'; import type { PreviewCandidateReportRequest, @@ -163,6 +164,24 @@ export interface SessionSteerResponse { error?: string; } +/** + * Answer to a goal control request. + * + * `accepted` means the machine took responsibility for the action, including + * when it is queued. `disposition` says how it reached the agent, which the + * caller cannot otherwise see: `applied` completed out of band, `turn_started` + * runs inside a prompt Lody just opened, and `queued` waits for the turn that + * currently owns the session's prompt slot. + */ +export interface SessionGoalResponse { + type: 'session/goal_response'; + sessionId: SessionId; + action: SessionGoalAction; + accepted: boolean; + disposition: 'applied' | 'turn_started' | 'queued' | 'unsupported' | 'error'; + error?: string; +} + // Permission Messages export interface PermissionRequestMessage { type: 'session/permission_request'; @@ -731,6 +750,7 @@ export type LocalSessionControlResponse = | SessionChatResponse | SessionCancelResponse | SessionSteerResponse + | SessionGoalResponse | MachineStatusResponse | MachinePingResponse | MachineRestartResponse diff --git a/packages/shared/tests/ai-capability-cache.test.ts b/packages/shared/tests/ai-capability-cache.test.ts index 9e3fe9d67..a9dae56bf 100644 --- a/packages/shared/tests/ai-capability-cache.test.ts +++ b/packages/shared/tests/ai-capability-cache.test.ts @@ -57,8 +57,10 @@ describe('ACP capability cache compatibility', () => { }); it('drops only the known-incompatible derived field from pre-v7 non-Codex entries', () => { + // Pinned to 6: the guard keys on "older than v7", not on the current + // version, so a later bump must not quietly stop exercising it. const capability: AcpCapabilityCacheEntry = { - ...entry(ACP_CAPABILITY_CACHE_VERSION - 1), + ...entry(6), agentType: 'claude', models: [{ modelId: 'opus[1m]', name: 'Opus 1M' }], modelReasoningEfforts: { opus: ['1m'] }, diff --git a/specs/session-goal-control.md b/specs/session-goal-control.md new file mode 100644 index 000000000..64b1e6b30 --- /dev/null +++ b/specs/session-goal-control.md @@ -0,0 +1,94 @@ +# Session goal control + +Status: draft +Translation: pending + +A goal is a standing objective attached to a session: the agent keeps working +toward it across several turns without the user re-asking. Codex is the first +agent to implement one. This Spec covers how a person starts, pauses, resumes, +and clears that goal, and what Lody guarantees while it runs. + +## Scenario + +Someone gives a session a goal and walks away. The agent works, finishes a turn, +decides on its own that the objective is not met, and starts another. Twenty +minutes later the person opens the session, sees the goal banner, and presses +**Pause**. They read the transcript, then press **Resume**. + +Both presses must take effect. That is the whole requirement, and it is not free: +while a goal is active it owns the session's only ACP prompt, and ACP v1 has no +second prompt to spare. + +## Responsibilities + +**The agent** owns goal state and scheduling. It decides when to continue, and +it publishes a snapshot — objective, status, usage — after every change. Lody +never infers goal status from turn activity. + +**Lody's CLI** owns turn attribution. Every unit of agent work must belong to a +conversation entry the user can see, cancel, and read later. Nothing else in the +product may create agent work outside that rule. + +**The UI** owns intent only. It shows what the agent published and asks for +actions the agent advertised; it never decides how an action is delivered. + +## Two transports, one reason + +A goal action either changes durable state or starts work, and ACP v1 treats +those very differently. + +`pause` and `clear` change state and nothing else. They travel out of band, as an +ACP extension request, and they work while a prompt is running. This is the +property that makes them useful at all: the prompt they need to interrupt is the +goal's own prompt, so an action that waited for a free prompt slot would wait for +the thing it is trying to stop. + +`set` and `resume` start work. A client can only own running work through its own +prompt, so Lody opens one and carries the action in its metadata. The agent +applies the action, adopts any turn it started natively, and keeps that prompt +open for the goal's remaining turns. The action never appears as user-visible +command text. + +An agent advertises which actions it supports and which transport carries each. +Lody offers exactly the advertised actions, for any agent — goal control is not +Codex-specific product behavior, only Codex-first availability. + +## Guarantees + +- A published goal status is authoritative for what the goal will do next. It is + not a statement about whether a turn is running right now: a paused goal may + still be draining its final turn. +- `pause` and `clear` are delivered without waiting for a turn. If the session + has no live agent, they fall back to the turn path, which starts the agent. +- `resume` and `set` never run concurrently with another turn. When one is + running, the action waits for it and then executes; it is not dropped and does + not require the user to stop the session first. Only the newest queued action + survives, so a stale pause cannot undo a later resume. +- A goal turn creates an assistant entry and no user message. It carries no run + configuration, so resuming a goal cannot silently change model or mode. +- Stopping a session cancels the turn first and then pauses the goal, so Stop + stays immediate. The agent also pauses a goal whose prompt it cancelled, which + is what closes the window between the two. +- Every goal action is a machine RPC subject to the same session access + verification as a chat message. + +## Unresolved + +Only Codex implements the goal extension today, so the neutral contract has one +producer. Whether other agents will express a comparable objective loop, and +whether `set` deserves a first-class UI beyond the `/goal` command, are open. + +## Evidence + +Intended behavior: this document. + +Inspected implementation: `packages/acp-extension-core` (`LodyGoalCapability`, +`LodyGoalPromptControl`), the Codex adapter's goal extension and its +`docs/goal-extension.md`, `apps/cli/src/agent/goal-control.ts`, +`SessionExecutionService.controlSessionGoal`, and +`packages/components/src/components/sessions/session-goal-control.ts`. + +Executed validation: adapter tests for the prompt-metadata transport, CLI tests +for transport selection and for queueing a resume behind a draining turn, and +component tests for capability-driven command availability. No live Codex goal +was exercised end to end. From d45dc50e966d9883ee16b60f0fa2d03e93e1c51b Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:12:05 +0000 Subject: [PATCH 2/9] chore: simplify goal controls and record ablation review Remove redundant goal fields, helpers, waiter values, and test details; update the Codex adapter pointer and document the ablation matrix and remaining P1 findings. Validation: targeted tests, full typecheck and lint, formatting, docs, and public boundary checks passed. Full check was stopped after reproducing the known component act failure. Model: gpt-5 --- .../2026-09-09-goal-control-plane.md | 5 + .../2026-09-10-goal-control-ablation.zh.md | 622 ++++++++++++++++++ apps/cli/src/agent/agent-client.ts | 2 +- apps/cli/src/agent/goal-control.ts | 4 +- apps/cli/src/lib/message-handler.ts | 1 - .../src/session/session-execution-service.ts | 28 +- .../tests/session-execution-service.test.ts | 55 +- .../app-updater-sparkle-policy.test.mjs | 15 +- packages/acp-extension-codex | 2 +- packages/components/src/atoms/runtime.ts | 1 - .../sessions/session-goal-control.ts | 16 +- .../src/hooks/use-session-actions.ts | 1 - .../providers/workspace-machine-rpc-facade.ts | 1 - .../tests/session-goal-control.test.ts | 7 +- packages/loro-streams-rpc/src/rpc.ts | 4 - packages/shared/src/goal.ts | 4 - packages/shared/src/local-machine-rpc.ts | 1 - 17 files changed, 672 insertions(+), 97 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-09-10-goal-control-ablation.zh.md diff --git a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md index 9dcce2c8e..b3e04d4d5 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -122,3 +122,8 @@ advertisement. Typecheck passes for shared, RPC, CLI, and components. Not verified: a live Codex session pausing and resuming a real goal, and the managed-runtime build path that consumes a published `acp-extension-core`. + +Follow-up correction (2026-09-10): the [independent review and ablation](../simplification/2026-09-10-goal-control-ablation.zh.md) +found gaps in startup acknowledgement, cross-transport supersession, and cold-session +status control. Those implementation defects remain unresolved; the intended +guarantees above are not evidence that these scenarios currently work. diff --git a/.agents/notes/implemented/simplification/2026-09-10-goal-control-ablation.zh.md b/.agents/notes/implemented/simplification/2026-09-10-goal-control-ablation.zh.md new file mode 100644 index 000000000..5fa74af60 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-09-10-goal-control-ablation.zh.md @@ -0,0 +1,622 @@ +# Goal control 审查与消融 + +Status: implemented +Translation: pending + +## 摘要 + +本次对 PR #554 的主仓与两个 ACP 子模块做独立审查和逐项消融。清理只针对没有独立作用的协议搬运字段、重复类型、未使用 helper 和测试实现耦合,不把测试全绿当作生产链路仍然成立的证明。确定性探针发现了启动确认、跨传输排队顺序和冷会话传输选择三个 P1 问题;这些是待修复的问题,不是本次简化已经实现的保证。保留 goal 的双传输契约和独立 turn 归属,但建议后续将 action 的接收、启动和执行结束明确分离。 + +关联:[PR #554](https://github.com/LodyAI/Lody/pull/554)、[原决策](../architecture/2026-09-09-goal-control-plane.md)、[draft Spec](../../../../specs/session-goal-control.md)。 + +## 范围与方法 + +- 主仓:`4400744a..9da5c12f`。浅克隆缺少与本地 `main` 的 merge base,使用该单提交的父提交比较,没有把不相关的 main 差异计入。 +- Core:`1aa2431..9e7503c`;Codex:`f9dbc8c..1a35bc0`,分别在子模块读取 diff。 +- 逐项临时删除新增 diff hunk,以及大 hunk 内的字段、函数、分支和测试;同一检查依赖图内每次还原后才进行下一项。独立的 CLI/components/Codex 批次可并行,CLI 类型文件清单确认不包含 components。类型检查失败时不将后续测试记为通过。 +- 单包检查不等于所有消费者检查;通过项还要追踪实际写入者、读取者和用户可见行为。对于测试删除,剩余测试通过只是实验结果,不是删除理由。 +- CLI 默认生产 tsconfig 排除测试;另外执行测试专用 tsconfig,在父提交和审查副本中均得到 746 条类型诊断,未作为通过信号。新 goal 测试区段未出现诊断,但这不等于整个测试目录类型检查通过。 +- 生命周期缺陷采用 synthetic fixtures、显式 deferred 和实际 owner guard 复现,没有真实 sleep、网络或 live Codex 会话。 + +## 审查发现(均为待修复 P1) + +### 1. RPC 把 goal 执行结束误当作启动确认 + +`controlSessionGoal` 等待 `startGoalTurn`,后者等待 `continueSession`;真实 `runVisibleSessionTurn` 等待完整 turn fiber。因此 idle Resume/Set 的 `turn_started` 不是启动确认,长任务会超过 facade 的 10 秒 RPC timeout。`session/goal` 又占用控制请求的四槽 semaphore:四个长运行 goal 请求可以占满控制通道,后续 Pause/Cancel 无法及时进入 handler。 + +另一个确定性探针在 metadata await 期间让普通聊天获得真实 owner guard:goal continuation 被 guard 拒绝,但 void 返回仍被上层报为 `accepted: true, disposition: turn_started`。建议将原子 slot claim、启动确认和完整执行 promise 分开;只 fire-and-forget 不解决重复/拒绝确认问题。 + +### 2. 新的 Pause/Clear 不会作废排队的 Resume + +`pendingGoalTurnBySession` 仅在 queue 路径更新。request 路径成功应用 Pause/Clear 后,旧 Resume 仍在 map 中;当前 turn release 后,它会再启动并覆盖用户更新的意图。Stop/cancel 同样没有作废该队列。探针分别验证了 `queue resume → apply pause/clear → release → resume` 的顺序。 + +建议由 session owner 对两种传输统一排序,明确新意图和 Stop 对 queued/preparing action 的失效语义。仅“每个队列保留最后一个元素”不等于整个 session 的 newest-wins。三次 turn 等待后的静默 drop 也与 draft Spec 的“不丢弃”保证存在张力;未将这一额外策略疑问单列为 P1。 + +### 3. 冷会话 Pause/Clear 的 prompt fallback 无法执行 + +无 live agent 时 service 进入 goal turn 路径。恢复后的 Codex 同时广告 request 和 prompt 对 Pause/Clear 的支持;通用 resolver 优先返回 `request`,但 `buildGoalControlPrompt` 仅接受 `promptMeta` 或 `slashCommand`,于是抛出 `ACP_GOAL_UNSUPPORTED`。通过实际 `AgentClient.prompt` 边界复现了两种 action。 + +建议按当前阶段选择传输:已经拥有 prompt 时从 prompt 支持集合选择;或者先恢复连接再发 status-only request,避免为状态变更打开工作 turn。双传输的职责划分本身有理由,但现有 resolver 不能同时代表全局偏好和当前可执行传输。 + +## 消融结果 + +检查绿但链路断的代表:删掉实际 prompt `_meta` 搬运或能力 transport 数组解析,原有测试都通过;补充实际 AgentClient/解析探针则失败。这两处必须保留,缺的是集成覆盖。完整逐项矩阵见文末,实际采用的联合简化另列 B 表。 + +## 实际采用的联合简化(B 表) + +这些实验在 A 表之外联合消除调用/定义关系,并在完整清理后重新检查所有受影响的包。临时的预期失败探针只在 `/tmp` 中,不作为断言 bug 永远存在的测试加入仓库。 + +| 编号 | 删了什么 | 什么挂了 / 没挂 | 结论与约束 | +| ---- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| B001 | goal RPC 的 timestamp:UI、runtime/facade、local schema、Loro schema/API/envelope params | 单删某层类型会失败;协调删除后六包类型检查和目标测试通过 | 删除。只生成/搬运/校验,不参与排队、执行或过期判断;过期由 RPC envelope 的 expiresAt 负责。只针对这次尚未发布的新方法,不代表可破坏已部署的混合版本协议。 | +| B002 | canPauseSessionGoal、仅测它的三条断言、空数组常量/特殊分支,以及测试只剩一次使用的 capability 别名 | 单删 helper,3 个测试失败;删除重复断言并直接 filter 后 helper/identity 12 测试通过 | 删除。UI 本来就用 goalCommands.includes;保留 5 个 helper 行为用例。保持返回值语义,不承诺跨调用的数组引用相等;唯一 UI 调用由 useMemo 缓存。 | +| B003 | waiter map 的 Promise 值、测试对这个内部值的 get/await | Map→Set 后原测试 2 失败;改为显式 continuationStarted 信号后 goal 子集 6 通过、完整 execution-service 88 通过 | 删除 Promise 存储,保留 Set 去重和 pending action map。生产只检查 membership,Promise 只有测试读取;容器形状不是排队契约。 | +| B004 | 新 goal fixture 的 hasSession=false 分支、isCreated/currentModel、session acpSessionId、repo/updateHistory、未观察的 mock 包装及返回 agentClient;opaque goal:resume: ID 前缀断言 | 合法联合简化后 goal 子集 6 通过,最终完整 CLI 目标套件 115 通过 | 删除。没有这些 fixture 分支/字段的读者;ID 无生产前缀解析器,前缀也不能证明没有用户消息。保留身份、transport、metadata、配置不变和 queue 行为断言。 | +| B005 | resume parser 单测 | 删后 adapter 两个目标文件 14 通过;再破坏 resume 解析,剩余 prompt 生命周期测试仍失败(1 失败/13 通过) | 删除重复覆盖;set、status-only 和非法 metadata parser 用例保留。 | +| B006 | GOAL_OBJECTIVE_MAX_LENGTH 的 export | adapter 类型检查及最终 36 个 goal 相关测试通过 | 删除无外部调用者的 export;常量本身仍由 slash/metadata 两条路径使用,保留 4000 上限及校验。 | +| B007 | MessageHandler 私有 fetchAcpCapabilities 返回类型中重复的 goalActions 注解 | 单项与联合类型/目标测试通过,生成的运行时转发不变 | 删除私有注解,不删除真正的缓存/协议字段或字段转发。 | +| B008 | Codex 本地未使用的 GoalCapability 上新增的两个 transport 类型字段 | 单项及联合 adapter 类型/测试通过 | 删除重复声明;Core 的同名字段、实际 CODEX_LODY_CAPABILITIES 广告和 CLI parser 全保留,后者有真实读写者。 | +| B009 | shared 导出的单调用 isStatusOnlySessionGoalAction | 内联原 pause/clear 判断后 transport/service 目标测试通过,六包类型检查通过 | 删除抽象,不删除限制;work-starting action 仍不允许走 request。 | +| B010 | 单调用 isSystemCausedDispatch | 内联 delivery/goal 判断后目标与完整 service 测试通过 | 删除抽象,不删除 assistant 归属/用户 dispatch 指针排除规则。 | +| B011 | goal ACK 的 response ?? {} 回退 | 合法 {} ACK 仍通过;旧代码在 null 拒绝探针中失败,删除回退后 9 个诊断探针通过 | 删除没有现有生产者的 null/undefined 宽松路径,非法响应现在明确报错。这不是所有非法输入下的等价变换。goal.optional 保留:实际 Codex extMethod 返回 {} 并另发 snapshot,不能误删真实兼容。Core response 类型与既有 producer 的细节差异未在本次扩大修正。 | +| B012 | Core 0.1.2 / Codex 对 0.1.2 的依赖升级(仅临时副本回退) | 本地降号后的 Core build、Codex typecheck 和 14 个目标测试仍通过 | 保留升级及相应锁文件。workspace/symlink 继续解析新源码,掩盖发布依赖;旧 Core 发布内容没有新增类型,不能把本地绿当作可发布证明。 | + +## 最终检查 + +- Core build 通过;六包正式 typecheck 通过,包含 Codex 的 examples 类型检查。 +- CLI 四文件共 115 测试通过(包含完整 execution-service 的 88 项);为避免已验证的 spawn git EPERM,在允许启动 git 的环境中运行。 +- Components goal helper/identity 12 通过;shared goal/cache 17 通过;RPC 两文件 100 通过。 +- Codex goal commands、prompt lifecycle、thread goal events、control transport 共 36 通过;删掉一个重复单测前相应集合为 37。 +- 临时 AgentClient/owner/queue 诊断探针共 9 通过,其中 6 个明确断言当前缺陷,并不意味着三个 P1 已修复。 +- 另一个临时探针执行真实 requestSessionGoal 编码:set objective 完整保留,不含 timestamp,实际输出同时被 local 请求 schema 和 Loro 参数 schema 接受(1 通过);它不是完整 server round-trip 测试。 +- 改动文件已格式化;定向 oxlint 为 0 errors / 16 warnings。未把它表述为全仓 lint 或 pnpm check 通过。 +- public-boundary 通过(4365 文件、21 manifests);docs check 通过,无 SHA-protected topics,仍有既有 AGENTS 大小预警;主仓与 Codex 的 diff --check 通过,Core 工作树干净。 +- 审查阶段未执行全仓 pnpm check;后续提交前执行:全仓 typecheck、lint 通过,测试阶段再次出现组件 `act is not a function`,随后提前终止该次检查。因此 pnpm check 未通过,组件全套未跑完,后续串行检查未执行;未运行 live E2E。不能把目标测试通过写成整个仓库通过。 +- 提交前 pnpm format 完成,剔除其产生的一处无关 Electron 测试格式变化;再次单独执行 docs check 与 public-boundary 均通过。 + +## 验证边界 + +已有失败没有直接按用户描述排除:主仓父提交的 gh-shim 仍为 5 失败/2 通过;components 父提交的代表性 React 渲染测试仍报 `React.act is not a function`;Codex 父提交 review slash 测试也在 40 秒超时。HEAD components 全套为 445 文件中 154 失败,3305 测试中 888 失败;没有声称在父提交重跑了整个组件套件。 + +未运行 live Codex 端到端场景;三个 P1 尚未修复。本次不改变 Spec 来迁就实现。审查阶段按要求未提交或推送;后续用户明确授权将清理结果与本记录提交并推送到现有分支。 + +## 完整逐项消融矩阵(A001–A355) + +355 项均执行完成:197 项在包类型检查失败后停止,59 项类型通过但目标测试失败,99 项两者通过。下表不是把“失败”一律等同于有用:单纯删除被引用的声明会产生语法/类型依赖,只有联合消除引用并保持合法行为才能判断能否简化;B 表记录了实际采用的联合实验。`类型` 指被选包的默认 tsconfig,Core 则检查 Codex 消费者并重建 Core;它不等于所有包及所有测试文件的联合类型检查。所有正常变异均在下一项之前还原;中途停止的一次批处理未计入,已还原并重新执行。 + +每行位置是被审查提交的原始行号;hunk 项是整块回退,其他项是块内声明、字段、分支或测试的独立删除/条件分支消融。部分 hunk 含既有代码的搬迁/重写,不能把删除后的编译错误误解为整块设计已经被证明必要。测试列只列第一个失败;完整本地日志命名为 `/tmp/goal-A编号-type.log` 和 `/tmp/goal-A编号-test.log`。窄测试选择:CLI transport/capability、execution-service 的 goal 子集、机器能力缓存;components goal helper;shared goal/cache;RPC 两套现有测试;adapter goal command/prompt lifecycle。 + +### `apps/cli/src/agent/acp-capabilities.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A001 / 36 | getGoalCapability: () => undefined, | 类型通过;测试失败:fetchAcpCapabilities > uses the current working directory for capability probing | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A002 / 130 | 测试:records the goal actions the live client advertised | 类型 / 测试通过 | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A132 / 133 | 测试:records the goal actions the live client advertised | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A133 / 146 | 测试:leaves goal actions absent for a runtime with no goal extension | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A257 / 134 | const startupResult = createSuccessfulStartupResult(); | 类型通过;测试失败:fetchAcpCapabilities > records the goal actions the live client advertised | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A258 / 141 | const result = await fetchAcpCapabilities('registry', 'goal-agent', createSilentLogger()); | 类型通过;测试失败:fetchAcpCapabilities > records the goal actions the live client advertised | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A259 / 149 | const result = await fetchAcpCapabilities('registry', 'plain-agent', createSilentLogger()); | 类型通过;测试失败:fetchAcpCapabilities > leaves goal actions absent for a runtime with no goal extension | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | + +### `apps/cli/src/agent/acp-capabilities.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | --------------------------------------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| A003 / 92 | goalActions: client.getGoalCapability()?.actions.slice(), | 类型通过;测试失败:fetchAcpCapabilities > records the goal actions the live client advertised | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | + +### `apps/cli/src/agent/acp-capability-normalization.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| A004 / 2 | type SessionGoalAction, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A005 / 15 | goalActions?: SessionGoalAction[]; | 类型失败;测试未运行:error TS2339: Property 'goalActions' does not exist on type 'AcpCapabilitiesResult'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A006 / 192 | goalActions?: SessionGoalAction[]; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'goalActions' does not exist in type '{ sessionFork?: boolean \| undefined; ac | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A007 / 230 | ...(lifecycleCapabilities.goalActions?.length ? { goalActions: lifecycleCapabilities.goalActions } : {}), | 类型通过;测试失败:fetchAcpCapabilities > records the goal actions the live client advertised | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | + +### `apps/cli/src/agent/agent-client.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| A008 / 10 | type LodyGoalCapability, | 类型失败;测试未运行:error TS2304: Cannot find name 'LodyGoalCapability'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A009 / 24 | type SessionGoalAction, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A010 / 82 | import { buildGoalPromptMeta, buildGoalSlashCommandText, resolveGoalActionTransport, GOAL_CONTROL_METHOD, type GoalActionTran | 类型失败;测试未运行:error TS2304: Cannot find name 'GoalActionTransport'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A011 / 1367 | getGoalCapability(): LodyGoalCapability \| undefined { return this.lodyExtensionCapabilities.goal; } resolveGoalActionTranspor | 类型失败;测试未运行:error TS2339: Property 'getGoalCapability' does not exist on type 'AgentClient'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A012 / 2455 | options?: { signal?: AbortSignal; \_meta?: acp.PromptRequest['_meta']; goalControl?: GoalPromptControl; } ) { const goalPrompt | 类型失败;测试未运行:error TS2304: Cannot find name 'goalPrompt'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A013 / 2490 | const promptMeta = goalPrompt?.\_meta ?? options?.\_meta; const promptPromise = this.connection?.prompt({ sessionId, prompt, .. | 类型 / 测试通过 | 保留:真实 connection.prompt 的元数据写入;原测试绿,补充 wire 探针失败。 | +| A134 / 1370 | getGoalCapability(): LodyGoalCapability \| undefined { return this.lodyExtensionCapabilities.goal; } | 类型失败;测试未运行:error TS2339: Property 'getGoalCapability' does not exist on type 'AgentClient'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A135 / 1374 | resolveGoalActionTransport(action: SessionGoalAction): GoalActionTransport \| null { return resolveGoalActionTransport(this.lo | 类型失败;测试未运行:error TS2339: Property 'resolveGoalActionTransport' does not exist on type 'AgentClient'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A136 / 1387 | async controlGoal(action: SessionGoalAction): Promise { if (this.resolveGoalActionTransport(action) !== 'request') { th | 类型失败;测试未运行:error TS2339: Property 'controlGoal' does not exist on type 'AgentClient'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A137 / 1388 | if (this.resolveGoalActionTransport(action) !== 'request') { throw new Error( `[ACP_GOAL_UNSUPPORTED] Agent did not advertise | 类型 / 测试通过 | 保留:禁止在 request 路径启动工作,守住 turn 归属边界。 | +| A138 / 1395 | if (!sessionId \|\| !connection) { throw new Error('[ACP_GOAL_UNAVAILABLE] ACP session is not connected'); } | 类型失败;测试未运行:error TS18047: 'connection' is possibly 'null'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A139 / 1398 | const response = await connection.request( | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'sessionId' does not exist in type '{ action: string; }'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A140 / 1405 | if (!parsed.success) { throw new Error( `[ACP_GOAL_INVALID_RESPONSE] Agent returned an invalid goal control response: ${parse | 类型失败;测试未运行:error TS18048: 'parsed.data' is possibly 'undefined'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A141 / 1423 | private buildGoalControlPrompt( prompt: acp.ContentBlock[], control: GoalPromptControl ): { prompt: acp.ContentBlock[]; \_meta | 类型失败;测试未运行:error TS2339: Property 'buildGoalControlPrompt' does not exist on type 'AgentClient'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A142 / 1426 | ): { prompt: acp.ContentBlock[]; \_meta?: acp.PromptRequest['_meta'] } { | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'prompt' does not exist in type '{ \_meta?: { [key: string]: unknown; } \| null | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A143 / 1428 | if (transport === 'promptMeta') { return { prompt, \_meta: buildGoalPromptMeta(control) }; } | 类型 / 测试通过 | 保留:将 goal action 写入 prompt metadata 的实际分支。 | +| A144 / 1431 | if (transport === 'slashCommand') { return { prompt: [{ type: 'text', text: buildGoalSlashCommandText(control) }] }; } | 类型 / 测试通过 | 保留:旧 runtime 没有 transport 广告时的 slash 兼容路径。 | +| A145 / 2459 | signal?: AbortSignal; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'signal' does not exist in type '{ \_meta?: { [key: string]: unknown; } \| null | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A146 / 2460 | \_meta?: acp.PromptRequest['_meta']; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and '\_meta' does not exist in type '{ signal?: AbortSignal \| undefined; goalContr | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A147 / 2466 | goalControl?: GoalPromptControl; | 类型失败;测试未运行:error TS2339: Property 'goalControl' does not exist on type '{ signal?: AbortSignal \| undefined; \_meta?: { [key: string]: unknown; } \| null \| und | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A148 / 2472 | if (goalPrompt) { prompt = goalPrompt.prompt; } | 类型 / 测试通过 | 保留:slash fallback 必须替换原始文本,而不只是计算后丢弃。 | +| A260 / 1393 | const sessionId = this.acpSessionId; | 类型失败;测试未运行:error TS2304: Cannot find name 'sessionId'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A261 / 1394 | const connection = this.connection; | 类型失败;测试未运行:error TS2663: Cannot find name 'connection'. Did you mean the instance member 'this.connection'? | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A262 / 1398 | const response = await connection.request( GOAL_CONTROL_METHOD, { sessionId, | 类型失败;测试未运行:error TS2552: Cannot find name 'response'. Did you mean 'Response'? | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A263 / 1402 | const parsed = z .object({ goal: LodyGoalSnapshotSchema.nullable().optional() }) .safeParse(response ?? {}); | 类型失败;测试未运行:error TS2304: Cannot find name 'parsed'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A264 / 1427 | const transport = this.resolveGoalActionTransport(control.action); | 类型失败;测试未运行:error TS2552: Cannot find name 'transport'. Did you mean 'WebTransport'? | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A265 / 2469 | const goalPrompt = options?.goalControl ? this.buildGoalControlPrompt(prompt, options.goalControl) : null; | 类型失败;测试未运行:error TS2304: Cannot find name 'goalPrompt'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A266 / 2479 | ...(options?.goalControl ? { goalAction: options.goalControl.action } : {}), | 类型 / 测试通过 | 保留:诊断 span 的 goalAction 归属,日志可区分控制 turn。 | + +### `apps/cli/src/agent/goal-control.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A014 / 1 | 测试:sends status-only actions out-of-band when the agent advertises them | 类型通过;测试失败:src/agent/goal-control.test.ts [ src/agent/goal-control.test.ts ] | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A149 / 16 | 测试:sends status-only actions out-of-band when the agent advertises them | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A150 / 26 | 测试:keeps work-starting actions inside a prompt even when the request lists them | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A151 / 38 | 测试:falls back to the slash bridge for runtimes that advertise no transports | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A152 / 45 | 测试:refuses actions the agent never advertised | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A153 / 58 | 测试:carries the action as metadata so no command text enters the conversation | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A154 / 67 | 测试:writes the slash bridge text a legacy runtime understands | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A267 / 9 | const capability = (overrides: Partial = {}): LodyGoalCapability => ({ version: 1, actions: ['set', 'paus | 类型通过;测试失败:resolveGoalActionTransport > sends status-only actions out-of-band when the agent advertises them | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A268 / 17 | const advertised = capability({ controlActions: ['pause', 'clear'], promptActions: ['set', 'pause', 'resume', 'clear'], }); | 类型通过;测试失败:resolveGoalActionTransport > sends status-only actions out-of-band when the agent advertises them | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A269 / 29 | const advertised = capability({ controlActions: ['set', 'pause', 'resume', 'clear'], promptActions: ['set', 'resume'], }); | 类型通过;测试失败:resolveGoalActionTransport > keeps work-starting actions inside a prompt even when the request lists them | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A270 / 39 | const legacy = capability(); | 类型通过;测试失败:resolveGoalActionTransport > falls back to the slash bridge for runtimes that advertise no transports | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | + +### `apps/cli/src/agent/goal-control.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | +| A015 / 1 | import type \* as acp from '@agentclientprotocol/sdk'; import { LODY_EXTENSION_METHODS, type LodyGoalCapability } from 'acp-ex | 类型失败;测试未运行:TS2306,删除后的 goal-control.ts 不再是模块 | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A155 / 24 | action: SessionGoalAction; | 类型失败;测试未运行:error TS2339: Property 'action' does not exist on type 'GoalPromptControl'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A156 / 36 | export function resolveGoalActionTransport( capability: LodyGoalCapability \| undefined, action: SessionGoalAction ): GoalActi | 类型失败;测试未运行:error TS2724: '"./goal-control"' has no exported member named 'resolveGoalActionTransport'. Did you mean 'GoalActionTransport'? | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A157 / 40 | if (!capability?.actions.includes(action)) { return null; } | 类型失败;测试未运行:error TS18048: 'capability' is possibly 'undefined'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A158 / 43 | if (capability.controlActions?.includes(action) && isStatusOnlySessionGoalAction(action)) { return 'request'; } | 类型通过;测试失败:resolveGoalActionTransport > sends status-only actions out-of-band when the agent advertises them | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A159 / 46 | if (capability.promptActions?.includes(action)) { return 'promptMeta'; } | 类型通过;测试失败:resolveGoalActionTransport > keeps work-starting actions inside a prompt even when the request lists them | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A160 / 54 | export function buildGoalPromptMeta(control: GoalPromptControl): acp.PromptRequest['_meta'] { return { lody: { goalControl: { | 类型失败;测试未运行:error TS2305: Module '"./goal-control"' has no exported member 'buildGoalPromptMeta'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A161 / 67 | export function buildGoalSlashCommandText(control: GoalPromptControl): string { return control.action === 'set' ? `/goal ${(c | 类型失败;测试未运行:error TS2305: Module '"./goal-control"' has no exported member 'buildGoalSlashCommandText'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A271 / 5 | export const GOAL_CONTROL_METHOD = LODY_EXTENSION_METHODS.sessionGoal; | 类型失败;测试未运行:error TS2305: Module '"./goal-control"' has no exported member 'GOAL_CONTROL_METHOD'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A272 / 11 | export const GOAL_CONTINUATION_PROMPT_TEXT = 'Continue working toward the active goal.'; | 类型失败;测试未运行:error TS2305: Module '"@/agent/goal-control"' has no exported member 'GOAL_CONTINUATION_PROMPT_TEXT'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A273 / 51 | return capability.controlActions \|\| capability.promptActions ? null : 'slashCommand'; | 类型通过;测试失败:resolveGoalActionTransport > refuses actions the agent never advertised | 保留:显式 transport 列表不支持时应拒绝,不能当成 legacy slash。 | +| A274 / 60 | ...(control.action === 'set' ? { objective: control.objective ?? '' } : {}), | 类型通过;测试失败:goal prompt payloads > carries the action as metadata so no command text enters the conversation | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A275 / 68 | return control.action === 'set' ? `/goal ${(control.objective ?? '').trim()}` : `/goal ${control.action}`; | 类型通过;测试失败:goal prompt payloads > writes the slash bridge text a legacy runtime understands | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | + +### `apps/cli/src/agent/lody-acp-extension.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------ | +| A016 / 43 | const GoalActionSchema = z.enum(['set', 'pause', 'resume', 'clear']); | 类型失败;测试未运行:error TS2304: Cannot find name 'GoalActionSchema'. | 保留:能力广告、传输选择或 prompt/ACK 编码仍使用;真实 wire 行为不由 mock 测试保证。 | +| A017 / 65 | actions: z.array(GoalActionSchema), controlActions: z.array(GoalActionSchema).optional(), promptActions: z.array(GoalActionSc | 类型 / 测试通过 | 保留:读取广告的传输集合;原测试绿,补充 parser 探针失败。 | + +### `apps/cli/src/lib/loro/doc.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| A018 / 52 | type SessionGoalAction, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:仍有实际调用/协议约束,未证明可无损删除。 | +| A019 / 1523 | goalActions?: SessionGoalAction[], | 类型失败;测试未运行:error TS2304: Cannot find name 'goalActions'. | 保留:仍有实际调用/协议约束,未证明可无损删除。 | +| A020 / 1544 | goalActions, | 类型失败;测试未运行:error TS2740: Type '{ signal?: AbortSignal \| undefined; }' is missing the following properties from type '("clear" \| "pause" \| "resume" \| "set")[ | 保留:仍有实际调用/协议约束,未证明可无损删除。 | +| A021 / 3219 | goalActions?: SessionGoalAction[], | 类型失败;测试未运行:error TS2304: Cannot find name 'goalActions'. | 保留:仍有实际调用/协议约束,未证明可无损删除。 | +| A022 / 3245 | goalActions: goalActions?.length ? goalActions : undefined, | 类型 / 测试通过 | 保留:将 goalActions 发布到机器文档,UI 实际读取;现有测试未覆盖。 | + +### `apps/cli/src/lib/loro/machine-document-capabilities.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A023 / 159 | undefined, | 类型通过;测试失败:MachineDocument ACP capabilities > does not write capabilities when cancelled while opening the Machine Flock | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | + +### `apps/cli/src/lib/message-handler.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| A024 / 133 | type SessionGoalAction, type SessionGoalResponse, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A025 / 2771 | private async controlSessionGoalWithAccessCheck(args: { sessionId: SessionId; action: SessionGoalAction; objective?: string; | 类型失败;测试未运行:error TS2551: Property 'controlSessionGoalWithAccessCheck' does not exist on type 'MessageHandler'. Did you mean 'forkSessionWithAccessCheck'? | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A026 / 3424 | controlSessionGoal: async (args) => await this.controlSessionGoalWithAccessCheck(args), | 类型 / 测试通过 | 保留:Loro RPC 的 goal handler 注册;删除后该路由不可用。 | +| A027 / 6716 | case 'session/goal': { return await this.controlSessionGoalWithAccessCheck({ ...request.params, sessionId: request.params.ses | 类型失败;测试未运行:error TS2322: Type '{ machineId: string; workspaceId: string; ownerSessionId?: string \| undefined; timeoutMs?: number \| undefined; method: "sessi | 保留:本地 RPC 的 goal 路由及访问检查。 | +| A028 / 8439 | goalActions?: SessionGoalAction[]; | 类型 / 测试通过 | 删除:私有方法的重复返回类型字段;值仍原样转发,调用方不读取此注解。 | +| A162 / 2774 | private async controlSessionGoalWithAccessCheck(args: { sessionId: SessionId; action: SessionGoalAction; objective?: string; | 类型失败;测试未运行:error TS2551: Property 'controlSessionGoalWithAccessCheck' does not exist on type 'MessageHandler'. Did you mean 'forkSessionWithAccessCheck'? | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A163 / 2775 | sessionId: SessionId; | 类型失败;测试未运行:error TS2339: Property 'sessionId' does not exist on type '{ action: "clear" \| "pause" \| "resume" \| "set"; objective?: string \| undefined; userId | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A164 / 2776 | action: SessionGoalAction; | 类型失败;测试未运行:error TS2339: Property 'action' does not exist on type '{ sessionId: SessionId; objective?: string \| undefined; userId: string; }'. | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A165 / 2777 | objective?: string; | 类型失败;测试未运行:error TS2339: Property 'objective' does not exist on type '{ sessionId: SessionId; action: "clear" \| "pause" \| "resume" \| "set"; userId: string; | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A166 / 2778 | userId: string; | 类型失败;测试未运行:error TS2339: Property 'userId' does not exist on type '{ sessionId: SessionId; action: "clear" \| "pause" \| "resume" \| "set"; objective?: string | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A167 / 2781 | if (access.outcome !== 'allowed') { return { type: 'session/goal_response', sessionId: args.sessionId, action: args.action, a | 类型 / 测试通过 | 保留:安全访问检查;测试全绿恰好说明缺少拒绝访问覆盖,不是冗余。 | +| A276 / 2780 | const access = await this.verifySessionMachineAccess(args.sessionId, args.userId); | 类型失败;测试未运行:error TS2304: Cannot find name 'access'. | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A277 / 2793 | const user = await this.sessionUserResolver.resolve(args.userId); | 类型失败;测试未运行:error TS2304: Cannot find name 'user'. | 保留:goal 路由、访问验证及请求者身份转发;两个 RPC 入口共享该边界。 | +| A278 / 2797 | ...(args.objective ? { objective: args.objective } : {}), | 类型 / 测试通过 | 保留:set objective 在访问验证后继续转发。 | + +### `apps/cli/src/session/session-execution-service.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A029 / 5 | type SessionGoalAction, type SessionGoalResponse, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A030 / 60 | import { randomUUID } from 'node:crypto'; | 类型失败;测试未运行:error TS2304: Cannot find name 'randomUUID'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A031 / 91 | import { GOAL_CONTINUATION_PROMPT_TEXT, type GoalPromptControl } from '@/agent/goal-control'; | 类型失败;测试未运行:error TS2304: Cannot find name 'GoalPromptControl'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A032 / 106 | import { resolveDispatchAcpSessionId, resolveResumableAcpSessionId, } from './session-dispatch-logic'; | 类型失败;测试未运行:error TS2304: Cannot find name 'resolveDispatchAcpSessionId'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A033 / 219 | type SessionGoalTurnRequest = { sessionId: SessionId; control: GoalPromptControl; userId: string; userName: string; userEmail | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalTurnRequest'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A034 / 247 | goalControl?: GoalPromptControl; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'goalControl' does not exist in type 'TurnRuntimeState'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A035 / 351 | goalControl?: GoalPromptControl; | 类型失败;测试未运行:error TS2344: Type '"goalControl" \| "invocation" \| "onTurnSettled" \| "session" \| "sessionId" \| "userTurnId"' does not satisfy the constraint 'key | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A036 / 372 | export type SessionDispatchSource = 'rpc' \| 'crdt' \| 'queue' \| 'delivery' \| 'goal'; const isSystemCausedDispatch = (source: S | 类型失败;测试未运行:error TS2322: Type '"goal"' is not assignable to type 'SessionDispatchSource \| undefined'. | 保留 goal dispatchSource 及其归属规则;单调用谓词内联(B010),不删除规则。 | +| A037 / 598 | goalActions?: SessionGoalAction[]; | 类型失败;测试未运行:error TS2339: Property 'goalActions' does not exist on type '{ modes: { id: string; name: string; description?: string \| undefined; }[]; models: | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A038 / 739 | private readonly pendingGoalTurnBySession = new Map(); private readonly goalTurnWaiterBySe | 类型失败;测试未运行:error TS2339: Property 'pendingGoalTurnBySession' does not exist on type 'SessionExecutionService'. | 保留 pending map 和 waiter 去重;仅 Promise 值存储改为 Set(见 B003)。 | +| A039 / 1202 | async controlSessionGoal(options: { sessionId: SessionId; action: SessionGoalAction; objective?: string; userId: string; user | 类型失败;测试未运行:error TS2339: Property 'controlSessionGoal' does not exist on type 'SessionExecutionService'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A040 / 1802 | 'sessionId' \| 'session' \| 'userTurnId' \| 'invocation' \| 'onTurnSettled' \| 'goalControl' | 类型失败;测试未运行:error TS2339: Property 'goalControl' does not exist on type 'Pick pauses out-of-band without opening a turn | 保留:有 live agent 时优先 capability/request 路径,不能都排队。 | +| A182 / 1240 | if (transport === null) { return respond('unsupported', `Agent does not support goal ${action}`); } | 类型通过;测试失败:SessionExecutionService goal control > refuses an action the agent never advertised | 保留:拒绝未广告 action,避免伪造 accepted 或普通聊天。 | +| A183 / 1243 | if (transport === 'request') { try { await agentClient.controlGoal(action); return respond('applied'); } catch (error) { this | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:状态动作绕过正在占用的 prompt;核心修复路径。 | +| A184 / 1269 | if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { this.queueGoalTurn(request); return respond('queued'); } | 类型通过;测试失败:SessionExecutionService goal control > waits for the running turn to release instead of dropping the resume | 保留:busy 时排队;空闲直接启动不能代替此分支。 | +| A185 / 1290 | private queueGoalTurn(request: SessionGoalTurnRequest): void { const { sessionId } = request; this.pendingGoalTurnBySession.s | 类型失败;测试未运行:error TS2339: Property 'queueGoalTurn' does not exist on type 'SessionExecutionService'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A186 / 1293 | if (this.goalTurnWaiterBySession.has(sessionId)) { return; } | 类型通过;测试失败:SessionExecutionService goal control > keeps only the newest queued action so a stale pause cannot undo a resume | 保留:每 session 仅一个 waiter,避免重复 continuation。 | +| A187 / 1300 | if (!snapshot.hasActiveTurn \|\| !snapshot.activeTurnId) { break; } | 类型失败;测试未运行:error TS2345: Argument of type 'string \| undefined' is not assignable to parameter of type 'string'. | 保留:会话释放后结束等待;防止无意义等待/读取空 turn。 | +| A188 / 1308 | if (!pending) return; | 类型失败;测试未运行:error TS18048: 'pending' is possibly 'undefined'. | 保留:队列项可能被错误清理;必须先确认存在,再启动。 | +| A189 / 1309 | if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { this.deps.logger.warn( `[${sessionId}] Dropping queued goal ${pendi | 类型 / 测试通过 | 保留:达到等待上限仍 busy 时不可并发启动;drop 策略另有待讨论问题。 | +| A190 / 1326 | private async startGoalTurn(request: SessionGoalTurnRequest): Promise { const { sessionId, control } = request; const s | 类型失败;测试未运行:error TS2339: Property 'startGoalTurn' does not exist on type 'SessionExecutionService'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A191 / 1330 | if (!meta) { throw new Error(`Session ${sessionId} has no metadata`); } | 类型失败;测试未运行:error TS18048: 'meta' is possibly 'undefined'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A192 / 1333 | if (meta.isArchived) { throw new Error(`Session ${sessionId} is archived`); } | 类型 / 测试通过 | 保留:归档会话不能由 goal 控制重启;当前窄测试未覆盖。 | +| A193 / 1336 | if (!meta.cliType \|\| !meta.agentType) { throw new Error(`Session ${sessionId} has no agent configuration`); } | 类型 / 测试通过 | 保留:没有 agent 配置时给明确启动错误;不能隐式选 runtime。 | +| A279 / 235 | const GOAL_TURN_QUEUE_MAX_WAITS = 3; | 类型失败;测试未运行:error TS2304: Cannot find name 'GOAL_TURN_QUEUE_MAX_WAITS'. | 保留:队列等待上限有实际调度作用;不是无效常量,策略问题另述。 | +| A280 / 382 | const isSystemCausedDispatch = (source: SessionDispatchSource \| undefined): boolean => source === 'delivery' \|\| source === 'g | 类型失败;测试未运行:error TS2304: Cannot find name 'isSystemCausedDispatch'. | 删除单调用谓词抽象并内联同一判断,保留 delivery/goal 的用户指针排除(B010)。 | +| A281 / 1223 | const { sessionId, action } = options; | 类型失败;测试未运行:error TS18004: No value exists in scope for the shorthand property 'sessionId'. Either declare one or provide an initializer. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A282 / 1224 | const respond = ( disposition: SessionGoalResponse['disposition'], error?: string ): SessionGoalResponse => ({ type: 'session | 类型失败;测试未运行:error TS2304: Cannot find name 'respond'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A283 / 1234 | ...(error ? { error } : {}), | 类型 / 测试通过 | 保留:失败响应携带可见错误信息。 | +| A284 / 1237 | const agentClient = this.deps.sessionManager.getSession(sessionId)?.agentClient; | 类型失败;测试未运行:error TS2304: Cannot find name 'agentClient'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A285 / 1239 | const transport = agentClient.resolveGoalActionTransport(action); | 类型失败;测试未运行:error TS2552: Cannot find name 'transport'. Did you mean 'WebTransport'? | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A286 / 1258 | const control: GoalPromptControl = { action, ...(options.objective ? { objective: options.objective } : {}), }; | 类型失败;测试未运行:error TS18004: No value exists in scope for the shorthand property 'control'. Either declare one or provide an initializer. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A287 / 1260 | ...(options.objective ? { objective: options.objective } : {}), | 类型 / 测试通过 | 保留:set objective 写入待执行控制对象。 | +| A288 / 1262 | const request: SessionGoalTurnRequest = { sessionId, control, userId: options.userId, userName: options.userName, userEmail: | 类型失败;测试未运行:error TS2552: Cannot find name 'request'. Did you mean 'Request'? | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A289 / 1291 | const { sessionId } = request; | 类型失败;测试未运行:error TS2304: Cannot find name 'sessionId'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A290 / 1296 | const waiter = (async () => { for (let attempt = 0; attempt < GOAL_TURN_QUEUE_MAX_WAITS; attempt += 1) { const snapshot = thi | 类型失败;测试未运行:error TS2304: Cannot find name 'waiter'. | 保留异步 waiter;仅去掉 Promise 值存储(B003)。 | +| A291 / 1299 | const snapshot = this.getExecutionSnapshot(sessionId); | 类型失败;测试未运行:error TS2304: Cannot find name 'snapshot'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A292 / 1305 | const pending = this.pendingGoalTurnBySession.get(sessionId); | 类型失败;测试未运行:error TS2304: Cannot find name 'pending'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A293 / 1327 | const { sessionId, control } = request; | 类型失败;测试未运行:error TS2552: Cannot find name 'sessionId'. Did you mean 'sessionDoc'? | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A294 / 1328 | const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); | 类型失败;测试未运行:error TS2552: Cannot find name 'sessionDoc'. Did you mean 'sessionId'? | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A295 / 1329 | const meta = await sessionDoc.getMetaState(); | 类型失败;测试未运行:error TS2304: Cannot find name 'meta'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A296 / 1339 | const resumeAcpSessionId = resolveDispatchAcpSessionId(meta); | 类型失败;测试未运行:error TS2304: Cannot find name 'resumeAcpSessionId'. | 保留:session owner 的排队、启动配置或 turn 归属链路仍使用该项;不以 mock 绿替代真实执行。 | +| A297 / 1346 | ...(meta.project ? { project: meta.project } : {}), | 类型 / 测试通过 | 保留:延续 session 的 project 归属。 | +| A298 / 1354 | ...(resumeAcpSessionId ? { resume: resumeAcpSessionId } : {}), | 类型 / 测试通过 | 保留:恢复原 ACP 会话,而不是新建丢失 goal 的会话。 | + +### `apps/cli/src/session/session.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------- | --------------- | -------------------------------------------------------- | +| A048 / 665 | goalActions: started.client.getGoalCapability()?.actions.slice(), | 类型 / 测试通过 | 保留:会话启动时同步 goalActions,不能仅依赖主动 probe。 | + +### `apps/cli/tests/session-execution-service.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| A049 / 2446 | true, undefined | 类型 / 测试通过 | 保留:新增可选位置参数的预期;窄 goal 筛选未运行原断言所在测试。 | +| A050 / 6171 | undefined, | 类型 / 测试通过 | 保留:新增可选位置参数的预期;窄 goal 筛选未运行原断言所在测试。 | +| A051 / 6529 | 测试:pauses out-of-band without opening a turn | 类型 / 测试通过 | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A194 / 6538 | transport?: 'request' \| 'promptMeta' \| 'slashCommand' \| null; | 类型 / 测试通过 | 保留:fixture transport 选项仍有 null/promptMeta 调用者。 | +| A195 / 6539 | hasSession?: boolean; | 类型 / 测试通过 | 删除:fixture 没有 hasSession=false 调用;连同死分支删除(B004)。 | +| A196 / 6583 | 测试:pauses out-of-band without opening a turn | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A197 / 6596 | 测试:refuses an action the agent never advertised | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A198 / 6606 | 测试:starts a Lody-owned turn for an action that resumes work | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A199 / 6625 | 测试:waits for the running turn to release instead of dropping the resume | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A200 / 6629 | currentTurnBySession: Map; | 类型 / 测试通过 | 保留:测试确实访问 currentTurnBySession;生产 tsconfig 不检查测试 cast。 | +| A201 / 6630 | clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; | 类型 / 测试通过 | 保留:测试通过 clearCurrentTurn 发出确定性 release 信号。 | +| A202 / 6631 | goalTurnWaiterBySession: Map>; | 类型 / 测试通过 | 删除:不再通过测试读取 waiter Promise;改等 continuation 信号(B003)。 | +| A203 / 6647 | 测试:keeps only the newest queued action so a stale pause cannot undo a resume | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A299 / 6534 | const goalSessionId = 'session-goal' as SessionId; | 类型通过;测试失败:tests/session-execution-service.test.ts [ tests/session-execution-service.test.ts ] | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A300 / 6536 | const createGoalService = ( overrides: { transport?: 'request' \| 'promptMeta' \| 'slashCommand' \| null; hasSession?: boolean; | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A301 / 6542 | const controlGoal = vi.fn(async () => {}); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A302 / 6543 | const agentClient = { isCreated: vi.fn(() => true), resolveGoalActionTransport: vi.fn(() => 'transport' in overrides ? overri | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A303 / 6546 | 'transport' in overrides ? overrides.transport : 'request' | 类型通过;测试失败:SessionExecutionService goal control > refuses an action the agent never advertised | 联合简化:fixture 改用 transport 默认参数;没有显式 undefined 的测试调用。 | +| A304 / 6551 | const getSession = vi.fn(() => overrides.hasSession === false ? null : { agentClient, acpSessionId: 'acp-goal' } ); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 联合删除未观察调用的 getSession mock 别名,内联为 fixture 属性(B004)。 | +| A305 / 6552 | overrides.hasSession === false ? null : { agentClient, acpSessionId: 'acp-goal' } | 类型通过;测试失败:tests/session-execution-service.test.ts [ tests/session-execution-service.test.ts ] | 联合删除 hasSession=false 死配置;单项 AST 替换还破坏了箭头返回对象语法,合法简化见 B004。 | +| A306 / 6554 | const deps = createBaseDeps({ sessionManager: { getSession, getPendingSession: vi.fn(() => null), } as unknown as SessionMana | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A307 / 6572 | const service = new SessionExecutionService(deps); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A308 / 6576 | const goalArgs = { sessionId: goalSessionId, userId: 'owner-user', userName: 'Owner', userEmail: 'owner@example.com', } as co | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A309 / 6584 | const { service, controlGoal } = createGoalService({ transport: 'request' }); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A310 / 6585 | const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A311 / 6587 | const response = await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); | 类型通过;测试失败:SessionExecutionService goal control > pauses out-of-band without opening a turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A312 / 6597 | const { service } = createGoalService({ transport: null }); | 类型通过;测试失败:SessionExecutionService goal control > refuses an action the agent never advertised | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A313 / 6598 | const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); | 类型通过;测试失败:SessionExecutionService goal control > refuses an action the agent never advertised | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A314 / 6600 | const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); | 类型通过;测试失败:SessionExecutionService goal control > refuses an action the agent never advertised | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A315 / 6607 | const { service } = createGoalService({ transport: 'promptMeta' }); | 类型通过;测试失败:SessionExecutionService goal control > starts a Lody-owned turn for an action that resumes work | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A316 / 6608 | const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); | 类型通过;测试失败:SessionExecutionService goal control > starts a Lody-owned turn for an action that resumes work | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A317 / 6610 | const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); | 类型通过;测试失败:SessionExecutionService goal control > starts a Lody-owned turn for an action that resumes work | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A318 / 6614 | const [request, options] = continueSession.mock.calls[0]!; | 类型通过;测试失败:SessionExecutionService goal control > starts a Lody-owned turn for an action that resumes work | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A319 / 6626 | const { service } = createGoalService({ transport: 'promptMeta' }); | 类型通过;测试失败:SessionExecutionService goal control > waits for the running turn to release instead of dropping the resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A320 / 6627 | const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); | 类型通过;测试失败:SessionExecutionService goal control > waits for the running turn to release instead of dropping the resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A321 / 6628 | const internals = service as unknown as { currentTurnBySession: Map; clearCurrentTurn: (sessionId: Session | 类型通过;测试失败:SessionExecutionService goal control > waits for the running turn to release instead of dropping the resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A322 / 6635 | const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); | 类型通过;测试失败:SessionExecutionService goal control > waits for the running turn to release instead of dropping the resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A323 / 6648 | const { service } = createGoalService({ transport: 'promptMeta' }); | 类型通过;测试失败:SessionExecutionService goal control > keeps only the newest queued action so a stale pause cannot undo a resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A324 / 6649 | const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); | 类型通过;测试失败:SessionExecutionService goal control > keeps only the newest queued action so a stale pause cannot undo a resume | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | + +### `packages/components/src/atoms/runtime.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A052 / 17 | SessionGoalAction, SessionGoalResponse, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A053 / 301 | requestSessionGoal: ( machineId: MachineId, args: { sessionId: SessionId; action: SessionGoalAction; objective?: string; user | 类型失败;测试未运行:error TS2551: Property 'requestSessionGoal' does not exist on type 'WorkspaceRuntime'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | + +### `packages/components/src/components/sessions/session-chat-interface.tsx` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A054 / 209 | getSessionGoalCommands, GOAL_COMMAND_PENDING_TIMEOUT_MS, | 类型失败;测试未运行:error TS2305: Module '"./session-goal-control"' has no exported member 'canPauseGoalThroughPromptBridge'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A055 / 2373 | requestSessionGoal, | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A056 / 2698 | const goalCapability = session.agentConfigId ? sessionMachine?.acpCapabilities?.[getAcpCapabilityCacheKey(session.agentConfig | 类型失败;测试未运行:error TS2304: Cannot find name 'getPromptBridgeGoalCommands'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A057 / 2737 | useEffect(() => { if (!pendingGoalCommand) { return undefined; } const timer = setTimeout(() => { setPendingGoalCommand((curr | 类型 / 测试通过 | 保留:pending 超时解锁及清理;helper 测试未执行 React effect。 | +| A058 / 4151 | if (options?.showPending !== false) { setPendingGoalCommand({ threadId: goal.threadId, command }); } try { const response = a | 类型失败;测试未运行:error TS2304: Cannot find name 'GOAL_PROMPT_DISPATCH_OPTIONS'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A059 / 4189 | [ captureSessionEvent, currentUser?.id, goalCommands, latestGoal, requestSessionGoal, session.id, session.machineId, session. | 类型 / 测试通过 | 保留:闭包依赖会话、机器和请求者,删除会使用陈旧路由。 | +| A060 / 5029 | 注释 / 文档说明 | 类型 / 测试通过 | 保留:仅说明 Stop 顺序的注释,无可执行行为。 | +| A204 / 4163 | if (!response?.accepted) { throw new Error( response?.error ?? `Goal command was ${response?.disposition ?? 'not delivered'}` | 类型失败;测试未运行:error TS18047: 'response' is possibly 'null'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A325 / 4159 | const response = await requestSessionGoal(session.id, command, { userId: currentUser?.id ?? session.userId, machineId: sessio | 类型失败;测试未运行:error TS2552: Cannot find name 'response'. Did you mean 'Response'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | + +### `packages/components/src/components/sessions/session-goal-control.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A061 / 1 | import { SESSION_GOAL_COMMANDS, type AcpCapabilityCacheEntry, type SessionGoalCommand, } from '@lody/shared'; const NO_GOAL_C | 类型失败;测试未运行:error TS2305: Module '"./session-goal-control"' has no exported member 'getSessionGoalCommands'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A205 / 20 | if (!actions?.length) { return NO_GOAL_COMMANDS; } | 类型失败;测试未运行:error TS18048: 'actions' is possibly 'undefined'. | 联合简化:改用 optional-chain filter,删空数组特殊分支(B002)。 | +| A326 / 16 | export const getSessionGoalCommands = ( capability: Pick \| undefined ): readonly Sess | 类型失败;测试未运行 | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A327 / 19 | const actions = capability?.goalActions; | 类型失败;测试未运行:error TS2304: Cannot find name 'actions'. | 联合简化:直接在 filter 内读取 optional goalActions(B002)。 | +| A328 / 23 | const supported = SESSION_GOAL_COMMANDS.filter((command) => actions.includes(command)); | 类型失败;测试未运行:error TS2304: Cannot find name 'supported'. | 联合简化:直接返回 filter 结果,无需中间 supported 变量(B002)。 | +| A329 / 24 | return supported.length > 0 ? supported : NO_GOAL_COMMANDS; | 类型通过;测试失败:session goal control availability > offers the commands the runtime advertised, whatever the agent is | 联合简化:保留 filter 结果,删空数组归一化;单独改成恒空数组当然会失败。 | +| A330 / 27 | export const canPauseSessionGoal = ( capability: Pick \| undefined ): boolean => getSe | 类型通过;测试失败:session goal control availability > keeps goals read-only for a runtime that advertises no goal actions | 删除:canPauseSessionGoal 仅由测试调用;UI 已读 goalCommands.includes。 | +| A331 / 40 | export const GOAL_COMMAND_PENDING_TIMEOUT_MS = 60_000; | 类型失败;测试未运行:error TS2305: Module '"./session-goal-control"' has no exported member 'GOAL_COMMAND_PENDING_TIMEOUT_MS'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | + +### `packages/components/src/hooks/use-session-actions.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A062 / 16 | SessionGoalAction, SessionGoalResponse, | 类型失败;测试未运行:error TS2552: Cannot find name 'SessionGoalAction'. Did you mean 'SessionActions'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A063 / 369 | requestSessionGoal: ( sessionId: SessionId, action: SessionGoalAction, options?: { objective?: string; userId?: string; machi | 类型失败;测试未运行:error TS2339: Property 'requestSessionGoal' does not exist on type 'SessionActions'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A064 / 922 | const requestSessionGoal = useCallback( async ( sessionId: SessionId, action: SessionGoalAction, options?: { objective?: stri | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A065 / 1478 | requestSessionGoal, | 类型失败;测试未运行:error TS2741: Property 'requestSessionGoal' is missing in type '{ createSession: (payload: SessionToCreate) => Promise; star | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A206 / 929 | options?: { objective?: string; userId?: string; machineId?: MachineId \| null } | 类型失败;测试未运行:error TS2339: Property 'objective' does not exist on type '{ userId?: string \| undefined; machineId?: MachineId \| null \| undefined; }'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A207 / 931 | if (!runtime) { throw new Error('Runtime not ready'); } | 类型失败;测试未运行:error TS18047: 'runtime' is possibly 'null'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A208 / 941 | if (!machineId \|\| !userId) { return null; } | 类型失败;测试未运行:error TS2345: Argument of type 'MachineId \| null' is not assignable to parameter of type 'MachineId'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A332 / 925 | const requestSessionGoal = useCallback( async ( sessionId: SessionId, action: SessionGoalAction, options?: { objective?: stri | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A333 / 934 | const roomId = getSessionRoomId(sessionId); | 类型失败;测试未运行:error TS2304: Cannot find name 'roomId'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A334 / 935 | const existing = await runtime.repo.getDocMeta(roomId); | 类型失败;测试未运行:error TS2304: Cannot find name 'existing'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A335 / 936 | const meta = isLoroRepoDocDeleted(existing) ? undefined : (existing?.meta as SessionMeta \| undefined); | 类型失败;测试未运行:error TS2304: Cannot find name 'meta'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A336 / 939 | const machineId = options?.machineId ?? meta?.machineId ?? null; | 类型失败;测试未运行:error TS2304: Cannot find name 'machineId'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A337 / 940 | const userId = options?.userId?.trim() \|\| meta?.userId; | 类型失败;测试未运行:error TS2304: Cannot find name 'userId'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A338 / 947 | ...(options?.objective ? { objective: options.objective } : {}), | 类型 / 测试通过 | 保留:UI API 的 set objective 转发,虽暂无独立按钮,协议确有读写者。 | + +### `packages/components/src/providers/create-workspace-runtime.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | +| A066 / 1657 | requestSessionGoal, | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A067 / 4626 | requestSessionGoal, | 类型失败;测试未运行:error TS2741: Property 'requestSessionGoal' is missing in type '{ workspaceSlug: string; workspaceId: WorkspaceId; repo: LoroRepo; co | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | + +### `packages/components/src/providers/workspace-machine-rpc-facade.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| A068 / 51 | type SessionGoalAction, type SessionGoalResponse, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A069 / 756 | const requestSessionGoal = async ( machineId: MachineId, args: { sessionId: SessionId; action: SessionGoalAction; objective?: | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A070 / 1165 | requestSessionGoal, | 类型失败;测试未运行:error TS2339: Property 'requestSessionGoal' does not exist on type '{ requestSessionCancel: (machineId: MachineId, sessionId: SessionId, turnId: | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A209 / 762 | sessionId: SessionId; | 类型失败;测试未运行 | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A210 / 763 | action: SessionGoalAction; | 类型失败;测试未运行:error TS2339: Property 'action' does not exist on type '{ sessionId: SessionId; objective?: string \| undefined; userId: string; timestamp: string | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A211 / 764 | objective?: string; | 类型 / 测试通过 | 保留:facade 的可调用接口承诺支持 set objective;运行时仅透传不等于没有协议消费者。 | +| A212 / 765 | userId: string; | 类型失败;测试未运行:error TS2345: Argument of type '{ machineId: MachineId; workspaceId: WorkspaceId; method: "session/goal"; params: { sessionId: SessionId; action: | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A213 / 766 | timestamp: string; | 类型失败;测试未运行:error TS2345: Argument of type '{ machineId: MachineId; workspaceId: WorkspaceId; method: "session/goal"; params: { sessionId: SessionId; action: | 联合删除 goal RPC timestamp(B001);仅删 facade 类型会与其余层不同步。 | +| A214 / 768 | options?: { timeoutMs?: number } | 类型失败;测试未运行:error TS2339: Property 'timeoutMs' does not exist on type '{}'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A215 / 779 | if (await canUseLocalMachineRpc(machineId)) { const response = await getLocalMachineRpcSender()?.({ machineId, workspaceId, m | 类型 / 测试通过 | 保留:优先本机 RPC;删除会改走远端通道,helper 测试没有执行 facade。 | +| A216 / 787 | if (response && !response.ok) { return failure(response.error); } | 类型 / 测试通过 | 保留:把本机 RPC 失败转换为 goal 失败响应;不能继续假作成功。 | +| A217 / 790 | if (response?.ok) return response.result as SessionGoalResponse; | 类型 / 测试通过 | 保留:本机 RPC 成功应直接返回,不能再发送一次远端 action。 | +| A339 / 759 | const requestSessionGoal = async ( machineId: MachineId, args: { sessionId: SessionId; action: SessionGoalAction; objective?: | 类型失败;测试未运行:error TS2552: Cannot find name 'requestSessionGoal'. Did you mean 'requestSessionCancel'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A340 / 770 | const failure = (error: string): SessionGoalResponse => ({ type: 'session/goal_response', sessionId: args.sessionId, action: | 类型失败;测试未运行:error TS2304: Cannot find name 'failure'. | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A341 / 780 | const response = await getLocalMachineRpcSender()?.({ machineId, workspaceId, method: 'session/goal', params: args, timeoutMs | 类型失败;测试未运行:error TS2552: Cannot find name 'response'. Did you mean 'Response'? | 保留:能力控制、请求路由或 UI pending 生命周期需要;helper 测试不覆盖整条 React/RPC 路径。 | +| A342 / 799 | return failure(error instanceof Error ? error.message : String(error)); | 类型 / 测试通过 | 保留:异常转换为可呈现的错误字符串。 | + +### `packages/components/tests/session-goal-control.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | --------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | +| A071 / 1 | 测试:keeps goals read-only for a runtime that advertises no goal actions | 类型通过;测试失败:session goal prompt bridge > keeps provider-neutral Claude goals read-only | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A218 / 9 | 测试:keeps goals read-only for a runtime that advertises no goal actions | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A219 / 15 | 测试:offers the commands the runtime advertised, whatever the agent is | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A220 / 25 | 测试:offers only the subset a partial runtime advertised | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A343 / 16 | const capability = { goalActions: ['set', 'pause', 'resume', 'clear'] as const }; | 类型通过;测试失败:session goal control availability > offers the commands the runtime advertised, whatever the agent is | 联合内联测试的 capability 字面量;移除仅剩一次使用的别名/readonly spread(B002)。 | + +### `packages/loro-streams-rpc/src/machine-rpc-server.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------- | +| A072 / 45 | SessionGoalAction, SessionGoalResponse, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A073 / 119 | 'session/goal', | 类型 / 测试通过 | 保留:控制通道归类;应修正长执行 ACK,不能靠删分类掩盖它。 | +| A074 / 357 | controlSessionGoal?: (args: { sessionId: SessionId; action: SessionGoalAction; objective?: string; userId: string; }) => Prom | 类型失败;测试未运行:error TS2339: Property 'controlSessionGoal' does not exist on type 'RpcServerDeps'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A075 / 1105 | case 'session/goal': { if (!this.deps.controlSessionGoal) { await this.appendErrorResponse(request.replyTo, request.id, reque | 类型 / 测试通过 | 保留:真实 session/goal handler;现有 RPC 测试未覆盖此 case。 | +| A076 / 1614 | \| SessionGoalResponse | 类型失败;测试未运行:error TS2345: Argument of type 'SessionGoalResponse' is not assignable to parameter of type 'MachineAcpAuthenticateResponse \| MachineAcpAuthentic | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A221 / 1109 | if (!this.deps.controlSessionGoal) { await this.appendErrorResponse(request.replyTo, request.id, request.method, { code: LORO | 类型失败;测试未运行:error TS2532: Object is possibly 'undefined'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A344 / 1116 | const response = await this.deps.controlSessionGoal({ sessionId: request.params.sessionId as SessionId, action: request.param | 类型失败;测试未运行:error TS2552: Cannot find name 'response'. Did you mean 'Response'? | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A345 / 1119 | ...(request.params.objective ? { objective: request.params.objective } : {}), | 类型 / 测试通过 | 保留:RPC 服务端将 set objective 交给业务层。 | + +### `packages/loro-streams-rpc/src/rpc.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | +| A077 / 45 | SessionGoalAction, SessionGoalResponse, | 类型失败;测试未运行:error TS2552: Cannot find name 'SessionGoalResponse'. Did you mean 'SessionCancelResponse'? | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A078 / 108 | SessionGoalResponseSchema, SESSION_GOAL_ACTIONS, | 类型失败;测试未运行:error TS2304: Cannot find name 'SESSION_GOAL_ACTIONS'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A079 / 193 | 'session/goal', | 类型失败;测试未运行:error TS2345: Argument of type '"code-collab/init-directory" \| "code-collab/lsp-definition" \| "code-collab/lsp-references" \| "code-collab/open-al | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A080 / 445 | export const LoroSessionGoalRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/goal'), params: z .ob | 类型失败;测试未运行:error TS2552: Cannot find name 'LoroSessionGoalRpcRequestSchema'. Did you mean 'LoroSessionCancelRpcRequestSchema'? | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A081 / 600 | LoroSessionGoalRpcRequestSchema, | 类型失败;测试未运行:error TS2678: Type '"session/goal"' is not comparable to type '"code-collab/init-directory" \| "code-collab/lsp-definition" \| "code-collab/lsp-ref | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A082 / 1443 | \| SessionGoalResponse | 类型失败;测试未运行:error TS2322: Type '{ type: "session/goal_response"; sessionId: SessionId; action: "clear" \| "pause" \| "resume" \| "set"; accepted: false; disposi | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A083 / 1471 | goalContext?: { sessionId: string; action: SessionGoalAction }, | 类型失败;测试未运行:error TS2304: Cannot find name 'goalContext'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A084 / 1624 | if (method === 'session/goal') { return { type: 'session/goal_response', sessionId: (goalContext?.sessionId ?? '') as Session | 类型 / 测试通过 | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A085 / 1820 | if (response.method === 'session/goal') { const parsed = SessionGoalResponseSchema.safeParse(response.result); return parsed. | 类型 / 测试通过 | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A086 / 1897 | goalContext?: { sessionId: string; action: SessionGoalAction }; | 类型失败;测试未运行:error TS2339: Property 'goalContext' does not exist on type 'LoroStreamsRpcPendingRequest'. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A087 / 2281 | finalPending.goalContext, | 类型失败;测试未运行:error TS2345: Argument of type '{ sessionId: string; } \| undefined' is not assignable to parameter of type '{ sessionId: string; action: "clear" | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A088 / 2314 | finalPending.goalContext, | 类型失败;测试未运行:error TS2345: Argument of type '{ sessionId: string; } \| undefined' is not assignable to parameter of type '{ sessionId: string; action: "clear" | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A089 / 2690 | async requestSessionGoal(options: { sessionId: string; action: SessionGoalAction; objective?: string; userId: string; timesta | 类型 / 测试通过 | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A090 / 3180 | method: 'session/goal'; timeoutMs: number; params: { sessionId: string; action: SessionGoalAction; objective?: string; userId | 类型失败;测试未运行:error TS2322: Type '"session/goal"' is not assignable to type '"code-collab/init-directory" \| "code-collab/lsp-definition" \| "code-collab/lsp-ref | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A091 / 3403 | goalContext: args.method === 'session/goal' ? { sessionId: args.params.sessionId, action: args.params.action } : undefined, | 类型 / 测试通过 | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A092 / 3515 | case 'session/goal': request = { ...envelope, method: args.method, params: args.params }; break; | 类型失败;测试未运行:error TS2454: Variable 'request' is used before being assigned. | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A093 / 3706 | pending.goalContext, | 类型失败;测试未运行:error TS2345: Argument of type '{ sessionId: string; } \| undefined' is not assignable to parameter of type '{ sessionId: string; action: "clear" | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A222 / 2693 | async requestSessionGoal(options: { sessionId: string; action: SessionGoalAction; objective?: string; userId: string; timesta | 类型 / 测试通过 | 保留:真实 RPC 客户端入口;删除后外包消费者失去方法,单包测试无覆盖。 | +| A223 / 2694 | sessionId: string; | 类型失败;测试未运行:error TS2339: Property 'sessionId' does not exist on type '{ action: "clear" \| "pause" \| "resume" \| "set"; objective?: string \| undefined; userId | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A224 / 2695 | action: SessionGoalAction; | 类型失败;测试未运行:error TS2339: Property 'action' does not exist on type '{ sessionId: string; objective?: string \| undefined; userId: string; timestamp: string; t | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A225 / 2696 | objective?: string; | 类型失败;测试未运行:error TS2339: Property 'objective' does not exist on type '{ sessionId: string; action: "clear" \| "pause" \| "resume" \| "set"; userId: string; tim | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A226 / 2697 | userId: string; | 类型失败;测试未运行:error TS2339: Property 'userId' does not exist on type '{ sessionId: string; action: "clear" \| "pause" \| "resume" \| "set"; objective?: string \| u | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A227 / 2698 | timestamp: string; | 类型失败;测试未运行:error TS2339: Property 'timestamp' does not exist on type '{ sessionId: string; action: "clear" \| "pause" \| "resume" \| "set"; objective?: string | 联合删除 goal RPC timestamp(B001);不能只删发送 API 的类型注解。 | +| A228 / 2699 | timeoutMs?: number; | 类型失败;测试未运行:error TS2339: Property 'timeoutMs' does not exist on type '{ sessionId: string; action: "clear" \| "pause" \| "resume" \| "set"; objective?: string | 保留:请求发送、响应解析/错误关联有实际消费者;单包检查不能代表 facade 仍可调用。 | +| A346 / 2707 | ...(options.objective ? { objective: options.objective } : {}), | 类型 / 测试通过 | 保留:RPC 客户端将 set objective 写入请求。 | + +### `packages/shared/src/ai.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------ | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------- | +| A094 / 7 | import type { SessionGoalAction } from './goal'; | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A095 / 282 | export const ACP_CAPABILITY_CACHE_VERSION = 8; | 类型 / 测试通过 | 保留:v7 缓存缺少 goalActions,需要 v8 强制刷新;检查绿不代表已有缓存会更新。 | +| A096 / 316 | goalActions?: SessionGoalAction[]; | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | + +### `packages/shared/src/goal.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| A097 / 12 | export const SESSION_GOAL_ACTIONS = ['set', 'pause', 'resume', 'clear'] as const; export type SessionGoalAction = (typeof SES | 类型失败;测试未运行:error TS2724: '"./goal"' has no exported member named 'SessionGoalAction'. Did you mean 'isSessionGoalActive'? | 保留 action tuple/type;单调用状态谓词内联到 transport selector(B009)。 | + +### `packages/shared/src/local-machine-rpc.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| A098 / 1 | import { SESSION_GOAL_ACTIONS } from './goal'; | 类型失败;测试未运行:error TS2304: Cannot find name 'SESSION_GOAL_ACTIONS'. | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A099 / 38 | SessionGoalResponseSchema, | 类型失败;测试未运行:error TS2552: Cannot find name 'SessionGoalResponseSchema'. Did you mean 'SessionCancelResponseSchema'? | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A100 / 205 | method: z.literal('session/goal'), params: z .object({ sessionId: SessionIdSchema, action: z.enum(SESSION_GOAL_ACTIONS), obje | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A101 / 272 | SessionGoalResponseSchema, | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | + +### `packages/shared/src/message-schemas.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| A102 / 9 | import { SESSION_GOAL_ACTIONS } from './goal'; | 类型失败;测试未运行:error TS2304: Cannot find name 'SESSION_GOAL_ACTIONS'. | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A103 / 685 | export const SessionGoalResponseSchema = z .object({ type: z.literal('session/goal_response'), sessionId: SessionIdSchema, ac | 类型失败;测试未运行:error TS2724: '"./message-schemas"' has no exported member named 'SessionGoalResponseSchema'. Did you mean 'SessionChatResponseSchema'? | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A104 / 1238 | goalActions: z.array(z.enum(SESSION_GOAL_ACTIONS)).optional(), | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A105 / 2035 | SessionGoalResponseSchema, | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | + +### `packages/shared/src/message.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| A106 / 17 | SessionGoalAction, | 类型失败;测试未运行:error TS2304: Cannot find name 'SessionGoalAction'. | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A107 / 164 | export interface SessionGoalResponse { type: 'session/goal_response'; sessionId: SessionId; action: SessionGoalAction; accept | 类型失败;测试未运行:error TS2552: Cannot find name 'SessionGoalResponse'. Did you mean 'SessionChatResponse'? | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A108 / 750 | \| SessionGoalResponse | 类型 / 测试通过 | 保留:公开响应/能力契约有实际写入和解析端;单包检查不含全部下游。 | +| A229 / 177 | type: 'session/goal_response'; | 类型 / 测试通过 | 保留:响应 type 是实际 schema 的判别字段,UI/RPC 都构造该响应。 | +| A230 / 178 | sessionId: SessionId; | 类型 / 测试通过 | 保留:响应关联 session,schema 要求并由发送端写入。 | +| A231 / 179 | action: SessionGoalAction; | 类型 / 测试通过 | 保留:响应关联 action,RPC 错误映射和 schema 都使用。 | +| A232 / 180 | accepted: boolean; | 类型 / 测试通过 | 保留:UI 根据 accepted 判断动作是否被接收。 | +| A233 / 181 | disposition: 'applied' \| 'turn_started' \| 'queued' \| 'unsupported' \| 'error'; | 类型 / 测试通过 | 保留:UI 的错误解释和事件归因读取 disposition。 | +| A234 / 182 | error?: string; | 类型 / 测试通过 | 保留:UI 展示 error;不能把服务端失败原因抹掉。 | + +### `packages/shared/tests/ai-capability-cache.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | +| A109 / 57 | const capability: AcpCapabilityCacheEntry = { ...entry(6), | 类型通过;测试失败:ACP capability cache compatibility > drops only the known-incompatible derived field from pre-v7 non-Codex entries | 保留:测试明确覆盖 older-than-v7 边界,动态 current−1 在 v8 已是不同场景。 | + +### `packages/acp-extension-core/src/capabilities.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| A110 / 11 | actions: readonly LodyGoalAction[]; controlActions?: readonly LodyGoalAction[]; promptActions?: readonly LodyGoalAction[]; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'controlActions' does not exist in type 'LodyGoalCapability'. | 保留:Core 拥有实际 metadata/能力 wire 契约;Codex 与 CLI 的读写依赖它。 | +| A235 / 21 | controlActions?: readonly LodyGoalAction[]; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'controlActions' does not exist in type 'LodyGoalCapability'. | 保留:Core 拥有实际 metadata/能力 wire 契约;Codex 与 CLI 的读写依赖它。 | +| A236 / 27 | promptActions?: readonly LodyGoalAction[]; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'promptActions' does not exist in type 'LodyGoalCapability'. | 保留:Core 拥有实际 metadata/能力 wire 契约;Codex 与 CLI 的读写依赖它。 | + +### `packages/acp-extension-core/src/session.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A111 / 69 | export type LodyGoalPromptControl = \| { version: 1; action: 'set'; objective: string } \| { version: 1; action: 'pause' \| 'res | 类型失败;测试未运行:error TS2305: Module '"acp-extension-core"' has no exported member 'LodyGoalPromptControl'. | 保留:Core 拥有实际 metadata/能力 wire 契约;Codex 与 CLI 的读写依赖它。 | +| A112 / 158 | goal?: LodyGoalSnapshot \| null; goalControl?: LodyGoalPromptControl; | 类型 / 测试通过 | 保留:真实生产者写入、消费者读取 goalControl;Core 是该 wire 字段的类型所有者。 | +| A237 / 86 | \| { version: 1; action: 'set'; objective: string } | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'version' does not exist in type '{ action: "set"; objective: string; }'. | 保留:prompt control 的版本判别由实际 parser 读取并验证。 | +| A238 / 87 | \| { version: 1; action: 'pause' \| 'resume' \| 'clear' }; | 类型失败;测试未运行:error TS2353: Object literal may only specify known properties, and 'version' does not exist in type '{ action: "clear" \| "pause" \| "resume"; }'. | 保留:非 set action 的版本化协议形状,不能让两个 union 分支不一致。 | + +### `packages/acp-extension-codex/src/AcpExtensions.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ---------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| A113 / 41 | parseGoalPromptControl, type GoalCapability, type GoalControlAction, type GoalControlRequest, type GoalPromptControl, | 类型失败;测试未运行:error TS2305: Module '"./AcpExtensions"' has no exported member 'parseGoalPromptControl'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A114 / 70 | goal: { version: 1, actions: ["set", "pause", "resume", "clear"], controlActions: ["pause", "clear"], promptActions: ["set", | 类型 / 测试通过 | 保留:Codex 的真实能力广告;删掉会退回 slash,失去 mid-prompt 状态控制。 | + +### `packages/acp-extension-codex/src/CodexAcpServer.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| A115 / 55 | import {CodexCommands, GOAL_CONTINUATION_PROMPT, type CommandHandleOptions} from "./CodexCommands"; | 类型失败;测试未运行:error TS2304: Cannot find name 'CommandHandleOptions'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A116 / 72 | parseGoalPromptControl, | 类型失败;测试未运行:error TS2552: Cannot find name 'parseGoalPromptControl'. Did you mean 'goalPromptControl'? | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A117 / 3018 | const goalPromptControl = parseGoalPromptControl(params.\_meta); | 类型失败;测试未运行:error TS2552: Cannot find name 'goalPromptControl'. Did you mean 'parseGoalPromptControl'? | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A118 / 3134 | const commandOptions: CommandHandleOptions = { | 类型失败;测试未运行:error TS1005: ')' expected. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A119 / 3163 | }; const commandPromise = goalPromptControl === null ? this.availableCommands.tryHandleCommand(params.prompt, sessionState, c | 类型失败;测试未运行:error TS1005: ',' expected. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | + +### `packages/acp-extension-codex/src/CodexCommands.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | --------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| A120 / 1 | import {RequestError, type AvailableCommand} from "@agentclientprotocol/sdk"; | 类型失败;测试未运行:error TS2304: Cannot find name 'RequestError'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A121 / 18 | import {GOAL_EXTENSION_VERSION, type GoalPromptControl} from "./GoalExtension"; | 类型失败;测试未运行:error TS2304: Cannot find name 'GOAL_EXTENSION_VERSION'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A122 / 29 | export const GOAL_OBJECTIVE_MAX_LENGTH = 4000; | 类型失败;测试未运行:error TS2304: Cannot find name 'GOAL_OBJECTIVE_MAX_LENGTH'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A123 / 381 | const named = argument.toLowerCase(); if (named === "pause" \|\| named === "resume" \|\| named === "clear") { return await this.r | 类型失败;测试未运行:error TS2339: Property 'runGoalPromptControl' does not exist on type 'CodexCommands'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A239 / 385 | if (named === "pause" \|\| named === "resume" \|\| named === "clear") { return await this.runGoalPromptControl( sessionState, {ve | 类型 / 测试通过 | 保留:slash 状态命令必须路由为状态操作,不能被当作新 objective。 | +| A240 / 393 | if (argument.length > GOAL_OBJECTIVE_MAX_LENGTH) { const session = new ACPSessionConnection(this.connection, sessionId); awai | 类型 / 测试通过 | 保留:slash 长度校验是此前已有行为,本次仅改用共享常量。 | +| A241 / 414 | async runGoalPromptControl( sessionState: SessionState, control: GoalPromptControl, options: CommandHandleOptions = {}, ): Pr | 类型失败;测试未运行:error TS2339: Property 'runGoalPromptControl' does not exist on type 'CodexCommands'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A242 / 420 | if (control.action === "pause") { await this.runWithProcessCheck(() => this.codexAcpClient.setGoalStatus(sessionId, "paused") | 类型 / 测试通过 | 保留:prompt pause 应暂停,不得落到 resume;契约调用者可达。 | +| A243 / 424 | if (control.action === "clear") { await this.runWithProcessCheck(() => this.codexAcpClient.clearGoal(sessionId)); return { ha | 类型 / 测试通过 | 保留:prompt clear 应清除,不得落到 resume;契约调用者可达。 | +| A244 / 428 | if (control.action === "set" && control.objective.trim().length > GOAL_OBJECTIVE_MAX_LENGTH) { throw RequestError.invalidPara | 类型 / 测试通过 | 保留:metadata set 在标记 turn pending / 调用 native 前校验长度。 | +| A347 / 384 | const named = argument.toLowerCase(); | 类型失败;测试未运行:error TS2552: Cannot find name 'named'. Did you mean 'name'? | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A348 / 419 | const sessionId = sessionState.sessionId; | 类型失败;测试未运行:error TS2304: Cannot find name 'sessionId'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A349 / 435 | const onTurnStarted = (turnId: string) => { this.handleCommandTurnStarted(sessionState, options, turnId, sessionId); }; | 类型失败;测试未运行:error TS2304: Cannot find name 'onTurnStarted'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A350 / 439 | control.action === "set" ? this.codexAcpClient.setGoal(sessionId, control.objective.trim(), onTurnStarted) : this.codexAcpCli | 类型 / 测试通过 | 保留:set 与 resume 必须分别调用 native setGoal/resumeGoal;当前集成只覆盖 resume。 | + +### `packages/acp-extension-codex/src/GoalExtension.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------- | +| A124 / 1 | import { LODY_EXTENSION_METHODS, type LodyGoalPromptControl, type LodyGoalSnapshot, } from "acp-extension-core"; | 类型失败;测试未运行:error TS2552: Cannot find name 'LodyGoalPromptControl'. Did you mean 'GoalPromptControl'? | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A125 / 14 | controlActions?: readonly GoalControlAction[]; promptActions?: readonly GoalControlAction[]; | 类型 / 测试通过 | 删除:重复的本地 GoalCapability transport 字段,没有生产类型使用者;Core 字段仍保留。 | +| A126 / 27 | export type GoalPromptControl = LodyGoalPromptControl; export function parseGoalPromptControl(meta: unknown): GoalPromptContr | 类型失败;测试未运行:error TS2305: Module '"./GoalExtension"' has no exported member 'parseGoalPromptControl'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A245 / 39 | export function parseGoalPromptControl(meta: unknown): GoalPromptControl \| null { if (typeof meta !== "object" \|\| meta === nu | 类型失败;测试未运行:error TS2724: '"./GoalExtension"' has no exported member named 'parseGoalPromptControl'. Did you mean 'GoalPromptControl'? | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A246 / 40 | if (typeof meta !== "object" \|\| meta === null) return null; | 类型通过;测试失败:parseGoalPromptControl > rejects metadata that cannot name a goal action | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A247 / 42 | if (typeof lody !== "object" \|\| lody === null) return null; | 类型 / 测试通过 | 保留:合法的非 goal 元数据可能没有 lody;删除会在读取 goalControl 时抛错。 | +| A248 / 44 | if (typeof control !== "object" \|\| control === null) return null; | 类型通过;测试失败:parseGoalPromptControl > rejects metadata that cannot name a goal action | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A249 / 46 | if (version !== GOAL_EXTENSION_VERSION) return null; | 类型通过;测试失败:parseGoalPromptControl > rejects metadata that cannot name a goal action | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A250 / 47 | if (action === "pause" \|\| action === "resume" \|\| action === "clear") { return {version: GOAL_EXTENSION_VERSION, action}; } | 类型通过;测试失败:parseGoalPromptControl > reads a resume action from prompt metadata | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A251 / 50 | if (action !== "set") return null; | 类型 / 测试通过 | 保留:未知 action 即使带 objective 也不能被转成 set;当前无此组合测试。 | +| A351 / 41 | const lody = (meta as Record)["lody"]; | 类型失败;测试未运行:error TS2304: Cannot find name 'lody'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A352 / 43 | const control = (lody as Record)["goalControl"]; | 类型失败;测试未运行:error TS2304: Cannot find name 'control'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A353 / 45 | const {version, action, objective} = control as Record; | 类型失败;测试未运行:error TS2304: Cannot find name 'version'. | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | +| A354 / 52 | return typeof objective === "string" && objective.trim().length > 0 ? {version: GOAL_EXTENSION_VERSION, action: "set", object | 类型通过;测试失败:parseGoalPromptControl > reads a set action with its objective | 保留:metadata/slash 路由或输入校验需要;删测试为绿不能替代真实 action 的行为判断。 | + +### `packages/acp-extension-codex/src/__tests__/CodexACPAgent/goal-prompt-lifecycle.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| A127 / 12 | async function startPrompt(prompt = "Pursue the test goal", meta?: Record) { | 类型失败;测试未运行:error TS2304: Cannot find name 'meta'. | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A128 / 41 | const response = agent.prompt({sessionId, prompt: [{type: "text", text: prompt}], ...(meta ? {\_meta: meta} : {})}) | 类型通过;测试失败:Goal continuation through ACP v1 prompt > resumes from prompt metadata without command text or a duplicate turn | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A129 / 85 | 测试:resumes from prompt metadata without command text or a duplicate turn | 类型 / 测试通过 | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A252 / 88 | 测试:resumes from prompt metadata without command text or a duplicate turn | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A355 / 89 | const run = await startPrompt("Continue working toward the active goal.", { lody: {goalControl: {version: 1, action: "resume" | 类型失败;测试未运行:error TS2304: Cannot find name 'run'. | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | + +### `packages/acp-extension-codex/src/__tests__/CodexCommands.goal.test.ts` + +| 编号 / 原行 | 删除或回退的项 | 类型 / 相关测试 | 结论与约束 | +| ----------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | +| A130 / 1 | import {parseGoalPromptControl} from "../GoalExtension"; | 类型失败;测试未运行:error TS2304: Cannot find name 'parseGoalPromptControl'. | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A131 / 41 | 测试:reads a resume action from prompt metadata | 类型 / 测试通过 | 保留:仍被执行的测试夹具/断言依赖;合法联合简化见 B002/B004,不能只删引用目标。 | +| A253 / 46 | 测试:reads a resume action from prompt metadata | 类型 / 测试通过 | 删除:resume parser 覆盖与保留的 prompt 生命周期测试重复;剩余测试能杀死 resume 解析突变。 | +| A254 / 51 | 测试:reads a set action with its objective | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A255 / 56 | 测试:reads status-only actions for sessions with no running prompt | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | +| A256 / 63 | 测试:rejects metadata that cannot name a goal action | 类型 / 测试通过 | 保留:独立输入/路径的行为覆盖;删测试后其余测试绿,不能证明该覆盖可替代。 | diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 32108fd45..4be1e2c04 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -1401,7 +1401,7 @@ export class AgentClient implements acp.Client { ); const parsed = z .object({ goal: LodyGoalSnapshotSchema.nullable().optional() }) - .safeParse(response ?? {}); + .safeParse(response); if (!parsed.success) { throw new Error( `[ACP_GOAL_INVALID_RESPONSE] Agent returned an invalid goal control response: ${parsed.error.message}` diff --git a/apps/cli/src/agent/goal-control.ts b/apps/cli/src/agent/goal-control.ts index 747051975..9fd714d45 100644 --- a/apps/cli/src/agent/goal-control.ts +++ b/apps/cli/src/agent/goal-control.ts @@ -1,6 +1,6 @@ import type * as acp from '@agentclientprotocol/sdk'; import { LODY_EXTENSION_METHODS, type LodyGoalCapability } from 'acp-extension-core'; -import { isStatusOnlySessionGoalAction, type SessionGoalAction } from '@lody/shared'; +import type { SessionGoalAction } from '@lody/shared'; export const GOAL_CONTROL_METHOD = LODY_EXTENSION_METHODS.sessionGoal; @@ -40,7 +40,7 @@ export function resolveGoalActionTransport( if (!capability?.actions.includes(action)) { return null; } - if (capability.controlActions?.includes(action) && isStatusOnlySessionGoalAction(action)) { + if (capability.controlActions?.includes(action) && (action === 'pause' || action === 'clear')) { return 'request'; } if (capability.promptActions?.includes(action)) { diff --git a/apps/cli/src/lib/message-handler.ts b/apps/cli/src/lib/message-handler.ts index b7f157a12..e4cb94c8d 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -8439,7 +8439,6 @@ export class MessageHandler { availableCommands?: NonNullable; sessionFork: boolean; acknowledgedSteer: boolean; - goalActions?: SessionGoalAction[]; modelReasoningEfforts?: Record; capabilitySourceVersion?: string; }> { diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 37070d031..01bbe7355 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -374,14 +374,6 @@ type VisibleSessionTurnPlan = { /** How the turn payload reached this machine (RPC fast path vs CRDT history vs queue promotion). */ export type SessionDispatchSource = 'rpc' | 'crdt' | 'queue' | 'delivery' | 'goal'; -/** - * Turns caused by Lody itself rather than by a user message. They own an - * assistant entry but must never touch user dispatch pointers, because no user - * turn is waiting on them. - */ -const isSystemCausedDispatch = (source: SessionDispatchSource | undefined): boolean => - source === 'delivery' || source === 'goal'; - type SessionDispatchOptions = { dispatchSource?: SessionDispatchSource; /** Goal action this turn exists to run; travels to the agent as prompt metadata. */ @@ -741,7 +733,7 @@ export class SessionExecutionService { private readonly turnReleaseWaiters = new Map void>>>(); /** At most one goal action waits per session; a newer action replaces it. */ private readonly pendingGoalTurnBySession = new Map(); - private readonly goalTurnWaiterBySession = new Map>(); + private readonly goalTurnWaiterSessions = new Set(); // Serializes ownership mutations per session so prompt completion and steer // application never race the boundary. No global concurrency cap (Infinity): // this is pure per-session serialization, matching the old hand-rolled lock. @@ -1290,10 +1282,10 @@ export class SessionExecutionService { private queueGoalTurn(request: SessionGoalTurnRequest): void { const { sessionId } = request; this.pendingGoalTurnBySession.set(sessionId, request); - if (this.goalTurnWaiterBySession.has(sessionId)) { + if (this.goalTurnWaiterSessions.has(sessionId)) { return; } - const waiter = (async () => { + void (async () => { // Bounded: a session the user keeps chatting in must not pin this loop. for (let attempt = 0; attempt < GOAL_TURN_QUEUE_MAX_WAITS; attempt += 1) { const snapshot = this.getExecutionSnapshot(sessionId); @@ -1304,7 +1296,7 @@ export class SessionExecutionService { } const pending = this.pendingGoalTurnBySession.get(sessionId); this.pendingGoalTurnBySession.delete(sessionId); - this.goalTurnWaiterBySession.delete(sessionId); + this.goalTurnWaiterSessions.delete(sessionId); if (!pending) return; if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { this.deps.logger.warn( @@ -1315,12 +1307,12 @@ export class SessionExecutionService { await this.startGoalTurn(pending); })().catch((error: unknown) => { this.pendingGoalTurnBySession.delete(sessionId); - this.goalTurnWaiterBySession.delete(sessionId); + this.goalTurnWaiterSessions.delete(sessionId); this.deps.logger.error( `[${sessionId}] Queued goal turn failed: ${formatErrorMessage(error)}` ); }); - this.goalTurnWaiterBySession.set(sessionId, waiter); + this.goalTurnWaiterSessions.add(sessionId); } private async startGoalTurn(request: SessionGoalTurnRequest): Promise { @@ -3669,9 +3661,11 @@ export class SessionExecutionService { prepareOptions?: { sessionDoc?: SessionDocument } ): Promise { const { sessionId, acpSessionConfig, userId, userName, userEmail, userTurnId } = message; - const executionUserTurnId = isSystemCausedDispatch(dispatchOptions?.dispatchSource) - ? undefined - : userTurnId; + // System-caused turns own an assistant entry, not a user dispatch pointer. + const executionUserTurnId = + dispatchOptions?.dispatchSource === 'delivery' || dispatchOptions?.dispatchSource === 'goal' + ? undefined + : userTurnId; const sessionDoc = prepareOptions?.sessionDoc ?? (await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId)); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 9f7b41cf1..60ccce92b 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -6533,44 +6533,34 @@ describe('SessionExecutionService', () => { describe('SessionExecutionService goal control', () => { const goalSessionId = 'session-goal' as SessionId; - const createGoalService = ( - overrides: { - transport?: 'request' | 'promptMeta' | 'slashCommand' | null; - hasSession?: boolean; - } = {} - ) => { + const createGoalService = ({ + transport = 'request', + }: { + transport?: 'request' | 'promptMeta' | 'slashCommand' | null; + } = {}) => { const controlGoal = vi.fn(async () => {}); const agentClient = { - isCreated: vi.fn(() => true), - resolveGoalActionTransport: vi.fn(() => - 'transport' in overrides ? overrides.transport : 'request' - ), + resolveGoalActionTransport: () => transport, controlGoal, - currentModel: undefined, }; - const getSession = vi.fn(() => - overrides.hasSession === false ? null : { agentClient, acpSessionId: 'acp-goal' } - ); const deps = createBaseDeps({ sessionManager: { - getSession, - getPendingSession: vi.fn(() => null), + getSession: () => ({ agentClient }), + getPendingSession: () => null, } as unknown as SessionManager, workspaceDocument: { - repo: { upsertDocMeta: vi.fn(async () => {}) }, - getOrCreateSessionDoc: vi.fn(async () => ({ - getMetaState: vi.fn(async () => ({ + getOrCreateSessionDoc: async () => ({ + getMetaState: async () => ({ id: goalSessionId, cliType: 'builtin', agentType: 'codex', acpSessionId: 'acp-goal', - })), - updateHistory: vi.fn(async () => {}), - })), + }), + }), } as unknown as LoroDocumentManager, }); const service = new SessionExecutionService(deps); - return { service, controlGoal, agentClient }; + return { service, controlGoal }; }; const goalArgs = { @@ -6616,19 +6606,20 @@ describe('SessionExecutionService goal control', () => { dispatchSource: 'goal', goalControl: { action: 'resume' }, }); - // No user message: the turn exists to carry the goal, not to say anything. - expect(request.userTurnId.startsWith('goal:resume:')).toBe(true); + // Resuming a goal must not select a new model or mode. expect(request.acpSessionConfig.modelId).toBeUndefined(); expect(request.acpSessionConfig.modeId).toBeUndefined(); }); it('waits for the running turn to release instead of dropping the resume', async () => { const { service } = createGoalService({ transport: 'promptMeta' }); - const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const continuationStarted = createDeferred(); + const continueSession = vi.spyOn(service, 'continueSession').mockImplementation(async () => { + continuationStarted.resolve(); + }); const internals = service as unknown as { currentTurnBySession: Map; clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; - goalTurnWaiterBySession: Map>; }; internals.currentTurnBySession.set(goalSessionId, 'draining-turn'); @@ -6638,7 +6629,7 @@ describe('SessionExecutionService goal control', () => { expect(continueSession).not.toHaveBeenCalled(); internals.clearCurrentTurn(goalSessionId, 'draining-turn'); - await internals.goalTurnWaiterBySession.get(goalSessionId); + await continuationStarted.promise; expect(continueSession).toHaveBeenCalledTimes(1); expect(continueSession.mock.calls[0]![1]).toMatchObject({ dispatchSource: 'goal' }); @@ -6646,11 +6637,13 @@ describe('SessionExecutionService goal control', () => { it('keeps only the newest queued action so a stale pause cannot undo a resume', async () => { const { service } = createGoalService({ transport: 'promptMeta' }); - const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const continuationStarted = createDeferred(); + const continueSession = vi.spyOn(service, 'continueSession').mockImplementation(async () => { + continuationStarted.resolve(); + }); const internals = service as unknown as { currentTurnBySession: Map; clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; - goalTurnWaiterBySession: Map>; }; internals.currentTurnBySession.set(goalSessionId, 'draining-turn'); @@ -6658,7 +6651,7 @@ describe('SessionExecutionService goal control', () => { await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); internals.clearCurrentTurn(goalSessionId, 'draining-turn'); - await internals.goalTurnWaiterBySession.get(goalSessionId); + await continuationStarted.promise; expect(continueSession).toHaveBeenCalledTimes(1); expect(continueSession.mock.calls[0]![1]).toMatchObject({ diff --git a/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs b/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs index 62596e28c..62305a3c7 100644 --- a/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs +++ b/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs @@ -12,18 +12,9 @@ import { } from './app-updater-sparkle-policy.ts' void test('keeps OSS local updater off unless explicitly force-enabled', () => { - assert.equal( - shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: false }), - false - ) - assert.equal( - shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: true }), - true - ) - assert.equal( - shouldConstructUpdaterEnabled({ localPlatform: false, forceEnable: false }), - true - ) + assert.equal(shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: false }), false) + assert.equal(shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: true }), true) + assert.equal(shouldConstructUpdaterEnabled({ localPlatform: false, forceEnable: false }), true) }) void test('uses Sparkle only for packaged macOS when the native bridge is available', () => { diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 1a35bc0e8..d7969e6f2 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 1a35bc0e8526c484aa15ec0bbfd3836daae2219b +Subproject commit d7969e6f28319b389c16f5d96c19cb26906172cb diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 7054b43d9..72d238003 100644 --- a/packages/components/src/atoms/runtime.ts +++ b/packages/components/src/atoms/runtime.ts @@ -308,7 +308,6 @@ export type WorkspaceRuntime = { action: SessionGoalAction; objective?: string; userId: string; - timestamp: string; }, options?: { timeoutMs?: number } ) => Promise; diff --git a/packages/components/src/components/sessions/session-goal-control.ts b/packages/components/src/components/sessions/session-goal-control.ts index c9fa47c95..76a756ac2 100644 --- a/packages/components/src/components/sessions/session-goal-control.ts +++ b/packages/components/src/components/sessions/session-goal-control.ts @@ -4,8 +4,6 @@ import { type SessionGoalCommand, } from '@lody/shared'; -const NO_GOAL_COMMANDS: readonly SessionGoalCommand[] = []; - /** * Goal commands the session's agent actually implements. * @@ -15,18 +13,8 @@ const NO_GOAL_COMMANDS: readonly SessionGoalCommand[] = []; */ export const getSessionGoalCommands = ( capability: Pick | undefined -): readonly SessionGoalCommand[] => { - const actions = capability?.goalActions; - if (!actions?.length) { - return NO_GOAL_COMMANDS; - } - const supported = SESSION_GOAL_COMMANDS.filter((command) => actions.includes(command)); - return supported.length > 0 ? supported : NO_GOAL_COMMANDS; -}; - -export const canPauseSessionGoal = ( - capability: Pick | undefined -): boolean => getSessionGoalCommands(capability).includes('pause'); +): readonly SessionGoalCommand[] => + SESSION_GOAL_COMMANDS.filter((command) => capability?.goalActions?.includes(command)); /** * How long a goal command may sit in its pending state before the UI stops diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index 8a15112ad..8f303a323 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -946,7 +946,6 @@ export function useSessionActions(): SessionActions { action, ...(options?.objective ? { objective: options.objective } : {}), userId, - timestamp: new Date().toISOString(), }); }, [runtime] diff --git a/packages/components/src/providers/workspace-machine-rpc-facade.ts b/packages/components/src/providers/workspace-machine-rpc-facade.ts index 2a890973e..8bb4ab4d6 100644 --- a/packages/components/src/providers/workspace-machine-rpc-facade.ts +++ b/packages/components/src/providers/workspace-machine-rpc-facade.ts @@ -763,7 +763,6 @@ export function createWorkspaceMachineRpcFacade(deps: WorkspaceMachineRpcFacadeD action: SessionGoalAction; objective?: string; userId: string; - timestamp: string; }, options?: { timeoutMs?: number } ): Promise => { diff --git a/packages/components/tests/session-goal-control.test.ts b/packages/components/tests/session-goal-control.test.ts index 6ac52a7ec..a2796b076 100644 --- a/packages/components/tests/session-goal-control.test.ts +++ b/packages/components/tests/session-goal-control.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from 'vitest'; import { - canPauseSessionGoal, getSessionGoalCommands, isSessionPromptBusy, } from '../src/components/sessions/session-goal-control'; @@ -9,24 +8,20 @@ describe('session goal control availability', () => { it('keeps goals read-only for a runtime that advertises no goal actions', () => { expect(getSessionGoalCommands(undefined)).toEqual([]); expect(getSessionGoalCommands({ goalActions: [] })).toEqual([]); - expect(canPauseSessionGoal(undefined)).toBe(false); }); it('offers the commands the runtime advertised, whatever the agent is', () => { - const capability = { goalActions: ['set', 'pause', 'resume', 'clear'] as const }; - expect(getSessionGoalCommands({ goalActions: [...capability.goalActions] })).toEqual([ + expect(getSessionGoalCommands({ goalActions: ['set', 'pause', 'resume', 'clear'] })).toEqual([ 'pause', 'resume', 'clear', ]); - expect(canPauseSessionGoal({ goalActions: [...capability.goalActions] })).toBe(true); }); it('offers only the subset a partial runtime advertised', () => { // `set` has no button of its own, and an unadvertised action must never get // one: pressing it would fail at the agent. expect(getSessionGoalCommands({ goalActions: ['set', 'clear'] })).toEqual(['clear']); - expect(canPauseSessionGoal({ goalActions: ['set', 'clear'] })).toBe(false); }); it('keeps a quiescent session direct-dispatchable while its goal remains active', () => { diff --git a/packages/loro-streams-rpc/src/rpc.ts b/packages/loro-streams-rpc/src/rpc.ts index ed651f34e..c9013a958 100644 --- a/packages/loro-streams-rpc/src/rpc.ts +++ b/packages/loro-streams-rpc/src/rpc.ts @@ -453,7 +453,6 @@ export const LoroSessionGoalRpcRequestSchema = BaseRpcRequestSchema.extend({ action: z.enum(SESSION_GOAL_ACTIONS), objective: z.string().trim().min(1).optional(), userId: z.string().trim().min(1), - timestamp: z.string().trim().min(1), }) .strict(), }).strict(); @@ -2695,7 +2694,6 @@ export class LoroStreamsMachineRpcClient { action: SessionGoalAction; objective?: string; userId: string; - timestamp: string; timeoutMs?: number; }): Promise { return (await this.sendRequest({ @@ -2706,7 +2704,6 @@ export class LoroStreamsMachineRpcClient { action: options.action, ...(options.objective ? { objective: options.objective } : {}), userId: options.userId, - timestamp: options.timestamp, }, })) as SessionGoalResponse | null; } @@ -3188,7 +3185,6 @@ export class LoroStreamsMachineRpcClient { action: SessionGoalAction; objective?: string; userId: string; - timestamp: string; }; } | { diff --git a/packages/shared/src/goal.ts b/packages/shared/src/goal.ts index 99268b0b1..10af2ab4a 100644 --- a/packages/shared/src/goal.ts +++ b/packages/shared/src/goal.ts @@ -17,10 +17,6 @@ export const SESSION_GOAL_ACTIONS = ['set', 'pause', 'resume', 'clear'] as const export type SessionGoalAction = (typeof SESSION_GOAL_ACTIONS)[number]; -/** Actions that never start work, so they can be delivered out-of-band. */ -export const isStatusOnlySessionGoalAction = (action: SessionGoalAction): boolean => - action === 'pause' || action === 'clear'; - export const sanitizeLodyInternalInstructions = (text: string): string => { const markerIndex = LODY_INTERNAL_PROMPT_MARKERS.reduce((earliest, marker) => { const index = text.indexOf(marker); diff --git a/packages/shared/src/local-machine-rpc.ts b/packages/shared/src/local-machine-rpc.ts index 84471d565..d6d3912bb 100644 --- a/packages/shared/src/local-machine-rpc.ts +++ b/packages/shared/src/local-machine-rpc.ts @@ -213,7 +213,6 @@ export const LocalMachineRpcRequestSchema = z.discriminatedUnion('method', [ action: z.enum(SESSION_GOAL_ACTIONS), objective: z.string().trim().min(1).optional(), userId: z.string().trim().min(1), - timestamp: z.string().trim().min(1), }) .strict(), }).strict(), From 889750d4fd570fb7bb5cff960edc62c4956a86c3 Mon Sep 17 00:00:00 2001 From: Lody Dev <62133302+wibus-wee@users.noreply.github.com> Date: Thu, 10 Sep 2026 05:13:01 +0000 Subject: [PATCH 3/9] chore: exclude unrelated Electron formatting from goal cleanup Restore the pre-review formatting; no behavior changes. Model: gpt-5 --- .../services/app-updater-sparkle-policy.test.mjs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs b/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs index 62305a3c7..62596e28c 100644 --- a/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs +++ b/apps/electron/src/main/services/app-updater-sparkle-policy.test.mjs @@ -12,9 +12,18 @@ import { } from './app-updater-sparkle-policy.ts' void test('keeps OSS local updater off unless explicitly force-enabled', () => { - assert.equal(shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: false }), false) - assert.equal(shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: true }), true) - assert.equal(shouldConstructUpdaterEnabled({ localPlatform: false, forceEnable: false }), true) + assert.equal( + shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: false }), + false + ) + assert.equal( + shouldConstructUpdaterEnabled({ localPlatform: true, forceEnable: true }), + true + ) + assert.equal( + shouldConstructUpdaterEnabled({ localPlatform: false, forceEnable: false }), + true + ) }) void test('uses Sparkle only for packaged macOS when the native bridge is available', () => { From 139128a2139c5eab4aecc052e44c61e97b794d4c Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 07:54:14 +0000 Subject: [PATCH 4/9] chore: pin Codex adapter to merged goal prompt PR Update acp-extension-codex from d7969e6 to merged PR #39 commit 33d897b. Validation: 161 targeted tests and docs check passed; pnpm format completed. Codex typecheck fails because the current Core pointer lacks LodyWorktreeProject and worktreeProject capabilities. Full pnpm check timed out. The Core integration remains unresolved; this commit only updates the requested pointer. Model: gpt-5 --- packages/acp-extension-codex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index d7969e6f2..33d897b35 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit d7969e6f28319b389c16f5d96c19cb26906172cb +Subproject commit 33d897b3583a239b9ba39cf48ab32388889e99bb From 616ccb54c42dda7a8cb843289150d58ac391fe74 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 08:34:16 +0000 Subject: [PATCH 5/9] chore: align goal adapters with published Core 0.1.4 Pin Core to its 0.1.4 release and Codex to the matching dependency update. Record successful registry-only installation, Codex typechecks, and 52 targeted tests. Validation: workspace typecheck, formatting, and docs checks passed. Full pnpm check timed out at two minutes during lint; full-suite success is not claimed. Model: gpt-5 --- .../architecture/2026-09-09-goal-control-plane.md | 10 ++++++++++ packages/acp-extension-codex | 2 +- packages/acp-extension-core | 2 +- 3 files changed, 12 insertions(+), 2 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md index cfd11d7e7..9e1862da4 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -141,3 +141,13 @@ Full workspace typecheck and lint also pass; `pnpm check` reaches tests but is terminated at the five-minute limit, so the full suite is not a passing signal. Formatting, docs check, and the public-boundary check pass; unrelated formatter churn is excluded from the merge. + +Release integration (2026-09-10): Core PR #7 is merged and 0.1.4 (`4c8ffe9`) +contains both contracts; the earlier 0.1.3 package did not include goal prompt +controls. Codex now pins 0.1.4 in its manifest and npm lock, including the registry +integrity. In a separate clone without the workspace override, +`npm ci --include=dev --ignore-scripts` installs the published package and both +Codex typechecks plus 52 goal/fork/worktree tests pass. Workspace frozen-lockfile +installation, Core build, and Codex typecheck also pass. The existing root pnpm +lock needs no change because Core remains a workspace link. This closes the +registry dependency gap above; it does not resolve the host-side P1 findings. diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index 33d897b35..ce7b0e83b 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit 33d897b3583a239b9ba39cf48ab32388889e99bb +Subproject commit ce7b0e83b78af1681a90995343692b36b1530737 diff --git a/packages/acp-extension-core b/packages/acp-extension-core index 2812417ca..4c8ffe929 160000 --- a/packages/acp-extension-core +++ b/packages/acp-extension-core @@ -1 +1 @@ -Subproject commit 2812417ca28b54c6b97a65248af40475dffc9215 +Subproject commit 4c8ffe929148aba5e216371323f5a27801113b34 From 925094aec440ef02f0c6af0ed89834ea7a2693df Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 08:42:59 +0000 Subject: [PATCH 6/9] chore: pin Codex submodule to merged Core dependency update Update the gitlink to merged PR #40 (5f0aab0). Its tree is identical to the previously validated ce7b0e8 commit; reuse the registry-backed typechecks and 52 passing tests. Docs check and diff check passed; full checks were not rerun for this pointer-only change. Model: gpt-5 --- packages/acp-extension-codex | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index ce7b0e83b..5f0aab0f6 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit ce7b0e83b78af1681a90995343692b36b1530737 +Subproject commit 5f0aab0f6614ef50dc57c2b137cdc0d4b864359e From b59797f33195f0a6dd64b1fc7ee9f1a5fce4c43e Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 12:17:15 +0000 Subject: [PATCH 7/9] fix: make goal control acceptance non-blocking and preserve queued intent Preserve typed /goal commands while fixing button acknowledgement, cold-session prompt transport, and queued action supersession. Remove the three-turn drop limit and surface startup failures. Validation: 117 CLI and 36 Codex goal tests passed. Workspace typecheck, lint, formatting, and docs checks passed. Full pnpm check timed out during tests after five minutes (exit 124). Model: gpt-5 --- .../2026-09-09-goal-control-plane.md | 45 ++- apps/cli/src/agent/agent-client.ts | 8 +- apps/cli/src/agent/goal-control.test.ts | 5 + apps/cli/src/agent/goal-control.ts | 12 +- apps/cli/src/session/AGENTS.md | 2 +- .../src/session/session-execution-service.ts | 109 ++++--- .../tests/agent-client-session-info.test.ts | 46 ++- .../tests/session-execution-service.test.ts | 285 +++++++++++++++--- specs/session-goal-control.md | 20 +- 9 files changed, 420 insertions(+), 112 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md index 9e1862da4..14cdf5d7b 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -51,14 +51,20 @@ Work-starting actions (`set`, `resume`) need somewhere to put the resulting turns, and ACP v1 gives a client exactly one such place: its own prompt. Core gained `LodyGoalPromptControl`, carried on `prompt._meta.lody.goalControl`, and the adapter routes it into the same code as the slash command. The conversation -never carries command text, and the adapter still adopts a turn Codex started +never carries button-generated command text; typed `/goal xxx` and the existing +subcommands remain supported. The adapter still adopts a turn Codex started natively rather than submitting a duplicate. The CLI owns the ordering. `SessionExecutionService.controlSessionGoal` sends the -request when the transport allows it; otherwise it opens a goal turn -(`dispatchSource: 'goal'`, no user message, no run configuration) or queues one -behind the running turn using the existing `waitForTurnRelease`. One queued action -per session, newest wins, bounded to three turn waits. +request when the transport allows it; otherwise it acknowledges `queued` and a +single session worker opens a goal turn (`dispatchSource: 'goal'`, no user message, +no run configuration), waiting for ownership through `waitForTurnRelease`. +Correction after PR #554 review: the original three-turn limit silently dropped +accepted requests. Accepted work now waits until it can run, is superseded, or +fails visibly. Metadata-loading contention retries against the new owner; claim +and pre-submission fences prevent a superseded request from reaching the provider. +Newer out-of-band Pause/Clear and exact-turn Stop invalidate pending goal work. +Acceptance does not wait for prompt completion and does not claim `turn_started`. The UI now reads `goalActions` from the ACP capability cache instead of testing `agentType === 'codex'`, and its pending state expires after a minute so a slow @@ -81,7 +87,9 @@ have to stay. **Allow only `set`/`resume` as prompt metadata.** Cleaner conceptually, but it leaves no way to clear a goal whose session is not running: the request path needs a live agent. Status-only actions are therefore accepted on both transports, with -the request preferred. +the request preferred for live control. Inside a restored, already-owned prompt, +transport selection must use the advertised prompt path instead of selecting a +request and then rejecting it as incompatible with a prompt. ## Consequences and limits @@ -89,7 +97,7 @@ the request preferred. `goalActions`. Until a session's machine refreshes, goal buttons are hidden rather than wrongly shown — the conservative direction. -`acp-extension-core` is now 0.1.2 and the Codex adapter depends on that version. +`acp-extension-core` is now 0.1.4 and the Codex adapter depends on that version. Inside this workspace the pnpm override resolves it to the local source; a published adapter build needs the new core release first. @@ -97,7 +105,7 @@ A paused goal can still be draining its last native turn, and that is now visibl rather than hidden: the queued resume waits and then runs. It no longer requires Stop, but it is not instant either. -Both submodules need their own PRs before the pointer bump here lands. +Both submodule changes have merged; these host corrections need no new release. The [goal control Spec](../../../../specs/session-goal-control.md) is draft. ## Verification @@ -125,8 +133,22 @@ managed-runtime build path that consumes a published `acp-extension-core`. Follow-up correction (2026-09-10): the [independent review and ablation](../simplification/2026-09-10-goal-control-ablation.zh.md) found gaps in startup acknowledgement, cross-transport supersession, and cold-session -status control. Those implementation defects remain unresolved; the intended -guarantees above are not evidence that these scenarios currently work. +status control. The host correction above addresses these findings and the +three-wait drop reported in [PR #554](https://github.com/LodyAI/Lody/pull/554). +New regression tests execute the actual host turn lifecycle with a deferred +provider, verify failures in session history, and exercise the AgentClient wire +payload for cold status controls alongside an unchanged `/goal xxx` prompt. +The queue remains process-local, not a daemon-restart recovery mechanism; no +durable queue or new protocol fields were introduced. +Correction validation: 117 host tests (execution service, AgentClient, transport +selection) and 36 Codex goal tests pass, including typed slash commands. CLI +production typecheck, repository lint, targeted formatting, and docs check pass. +The separate CLI test tsconfig still fails on existing fixture/type errors and +is not counted as passing validation. +Pre-commit validation reran `pnpm check`: workspace typecheck and lint passed, +but the test phase hit the five-minute limit (exit 124), so the complete check +is not passing evidence. `pnpm format` completed; unrelated Electron formatter +churn was excluded. Live Codex end-to-end behavior was not rerun. Merge integration (2026-09-10): retain both `SessionGoalAction` and Core's `createPlanModeConfigOption` imports when merging main. Core's goal branch now @@ -150,4 +172,5 @@ integrity. In a separate clone without the workspace override, Codex typechecks plus 52 goal/fork/worktree tests pass. Workspace frozen-lockfile installation, Core build, and Codex typecheck also pass. The existing root pnpm lock needs no change because Core remains a workspace link. This closes the -registry dependency gap above; it does not resolve the host-side P1 findings. +registry dependency gap above; host-side findings are addressed separately by +the correction recorded in this note. diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index 4144cf7f7..ca4521fc8 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -1429,7 +1429,13 @@ export class AgentClient implements acp.Client { prompt: acp.ContentBlock[], control: GoalPromptControl ): { prompt: acp.ContentBlock[]; _meta?: acp.PromptRequest['_meta'] } { - const transport = this.resolveGoalActionTransport(control.action); + // This path already owns a prompt (including cold-session restoration). + // Prefer the advertised prompt transport, not a live-session request. + const transport = resolveGoalActionTransport( + this.lodyExtensionCapabilities.goal, + control.action, + 'prompt' + ); if (transport === 'promptMeta') { return { prompt, _meta: buildGoalPromptMeta(control) }; } diff --git a/apps/cli/src/agent/goal-control.test.ts b/apps/cli/src/agent/goal-control.test.ts index 4a7f50057..f40f6dff2 100644 --- a/apps/cli/src/agent/goal-control.test.ts +++ b/apps/cli/src/agent/goal-control.test.ts @@ -21,6 +21,8 @@ describe('resolveGoalActionTransport', () => { expect(resolveGoalActionTransport(advertised, 'pause')).toBe('request'); expect(resolveGoalActionTransport(advertised, 'clear')).toBe('request'); + expect(resolveGoalActionTransport(advertised, 'pause', 'prompt')).toBe('promptMeta'); + expect(resolveGoalActionTransport(advertised, 'clear', 'prompt')).toBe('promptMeta'); }); it('keeps work-starting actions inside a prompt even when the request lists them', () => { @@ -44,6 +46,9 @@ describe('resolveGoalActionTransport', () => { it('refuses actions the agent never advertised', () => { expect(resolveGoalActionTransport(undefined, 'pause')).toBeNull(); + expect( + resolveGoalActionTransport(capability({ controlActions: ['pause'] }), 'pause', 'prompt') + ).toBeNull(); expect(resolveGoalActionTransport(capability({ actions: ['set'] }), 'resume')).toBeNull(); expect( resolveGoalActionTransport( diff --git a/apps/cli/src/agent/goal-control.ts b/apps/cli/src/agent/goal-control.ts index 9fd714d45..0d749c0b3 100644 --- a/apps/cli/src/agent/goal-control.ts +++ b/apps/cli/src/agent/goal-control.ts @@ -31,16 +31,22 @@ export type GoalPromptControl = { * The out-of-band request wins whenever the agent advertises it, because it is * the only transport that does not need a turn — and a goal's own prompt can * hold the session's single prompt slot for hours. Everything else runs inside - * a prompt so the turns it starts belong to a conversation entry. + * a prompt so the turns it starts belong to a conversation entry. Inside an + * already-owned prompt (including cold restore), select only a prompt transport. */ export function resolveGoalActionTransport( capability: LodyGoalCapability | undefined, - action: SessionGoalAction + action: SessionGoalAction, + context: 'control' | 'prompt' = 'control' ): GoalActionTransport | null { if (!capability?.actions.includes(action)) { return null; } - if (capability.controlActions?.includes(action) && (action === 'pause' || action === 'clear')) { + if ( + context === 'control' && + capability.controlActions?.includes(action) && + (action === 'pause' || action === 'clear') + ) { return 'request'; } if (capability.promptActions?.includes(action)) { diff --git a/apps/cli/src/session/AGENTS.md b/apps/cli/src/session/AGENTS.md index 2af2bd6d7..7ebe6819e 100644 --- a/apps/cli/src/session/AGENTS.md +++ b/apps/cli/src/session/AGENTS.md @@ -46,7 +46,7 @@ Contract: specs/session-orchestration.md. - Gate turn-scoped history LIST writes on user-entry sync (`turn-history-gate.ts`, 20s); never gate status or meta writes. -- An `active` session goal must not suppress turn completion or its notification. +- Goals obey [this contract](../../../../specs/session-goal-control.md). - Keep `TurnRuntimeState` until raw ACP completion or confirmed termination after cancel; no second visible turn. Assistant ids use `userTurnId`. `invocation` atomically owns source Turn, requester and config; steer replaces it before tools. diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 35735d335..9263b09b7 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -247,13 +247,6 @@ type SessionGoalTurnRequest = { userEmail: string; }; -/** - * How many consecutive turns a queued goal action waits out. Each wait is one - * whole turn, so this is generous for a drain and still bounded for a session - * the user keeps chatting in. - */ -const GOAL_TURN_QUEUE_MAX_WAITS = 3; - type TurnInvocation = { /** Causal input Turn for authorization and durable provenance. */ sourceTurnId: string; @@ -1253,6 +1246,8 @@ export class SessionExecutionService { return respond('unsupported', `Agent does not support goal ${action}`); } if (transport === 'request') { + // A later Pause/Clear supersedes work that has not reached the provider. + this.pendingGoalTurnBySession.delete(sessionId); try { await agentClient.controlGoal(action); return respond('applied'); @@ -1278,19 +1273,10 @@ export class SessionExecutionService { userName: options.userName, userEmail: options.userEmail, }; - if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { - this.queueGoalTurn(request); - return respond('queued'); - } - try { - await this.startGoalTurn(request); - return respond('turn_started'); - } catch (error) { - this.deps.logger.warn( - `[${sessionId}] Goal ${action} turn failed to start: ${formatErrorMessage(error)}` - ); - return respond('error', formatErrorMessage(error)); - } + // Acceptance is not prompt completion (or even a claim of turn ownership). + // The worker reports startup failures through the session's existing history. + this.queueGoalTurn(request); + return respond('queued'); } /** @@ -1305,40 +1291,55 @@ export class SessionExecutionService { if (this.goalTurnWaiterSessions.has(sessionId)) { return; } + this.goalTurnWaiterSessions.add(sessionId); void (async () => { - // Bounded: a session the user keeps chatting in must not pin this loop. - for (let attempt = 0; attempt < GOAL_TURN_QUEUE_MAX_WAITS; attempt += 1) { + while (this.pendingGoalTurnBySession.has(sessionId)) { const snapshot = this.getExecutionSnapshot(sessionId); - if (!snapshot.hasActiveTurn || !snapshot.activeTurnId) { - break; + if (snapshot.hasActiveTurn && snapshot.activeTurnId) { + await this.waitForTurnRelease(sessionId, snapshot.activeTurnId); + continue; + } + const pending = this.pendingGoalTurnBySession.get(sessionId); + if (!pending) return; + try { + const claimed = await this.startGoalTurn(pending); + if (this.pendingGoalTurnBySession.get(sessionId) !== pending) continue; + // Another dispatch may win while metadata is loading. Retain the + // accepted request and wait for its owner instead of reporting success. + if (!claimed && this.getExecutionSnapshot(sessionId).hasActiveTurn) continue; + if (!claimed) throw new Error('Goal turn could not acquire session ownership'); + this.pendingGoalTurnBySession.delete(sessionId); + // A claimed turn that never submitted its prompt already records its + // startup/cancellation outcome through the ordinary turn lifecycle. + } catch (error) { + if (this.pendingGoalTurnBySession.get(sessionId) !== pending) continue; + this.pendingGoalTurnBySession.delete(sessionId); + const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); + await this.deps.recordChatFailure( + sessionDoc, + 'turn_pre_prompt_failed', + `Goal ${pending.control.action} failed: ${formatErrorMessage(error)}` + ); } - await this.waitForTurnRelease(sessionId, snapshot.activeTurnId); } - const pending = this.pendingGoalTurnBySession.get(sessionId); - this.pendingGoalTurnBySession.delete(sessionId); - this.goalTurnWaiterSessions.delete(sessionId); - if (!pending) return; - if (this.getExecutionSnapshot(sessionId).hasActiveTurn) { - this.deps.logger.warn( - `[${sessionId}] Dropping queued goal ${pending.control.action}: session stayed busy` + })() + .catch((error: unknown) => { + this.deps.logger.error( + `[${sessionId}] Failed to report queued goal failure: ${formatErrorMessage(error)}` ); - return; - } - await this.startGoalTurn(pending); - })().catch((error: unknown) => { - this.pendingGoalTurnBySession.delete(sessionId); - this.goalTurnWaiterSessions.delete(sessionId); - this.deps.logger.error( - `[${sessionId}] Queued goal turn failed: ${formatErrorMessage(error)}` - ); - }); - this.goalTurnWaiterSessions.add(sessionId); + }) + .finally(() => { + this.goalTurnWaiterSessions.delete(sessionId); + const pending = this.pendingGoalTurnBySession.get(sessionId); + if (pending) this.queueGoalTurn(pending); + }); } - private async startGoalTurn(request: SessionGoalTurnRequest): Promise { + private async startGoalTurn(request: SessionGoalTurnRequest): Promise { const { sessionId, control } = request; const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); + if (this.pendingGoalTurnBySession.get(sessionId) !== request) return false; if (!meta) { throw new Error(`Session ${sessionId} has no metadata`); } @@ -1349,6 +1350,7 @@ export class SessionExecutionService { throw new Error(`Session ${sessionId} has no agent configuration`); } const resumeAcpSessionId = resolveDispatchAcpSessionId(meta); + let claimed = false; await this.continueSession( { type: 'session/chat', @@ -1372,8 +1374,24 @@ export class SessionExecutionService { userName: request.userName, userEmail: request.userEmail, }, - { dispatchSource: 'goal', goalControl: control } + { + dispatchSource: 'goal', + goalControl: control, + onTurnClaimed: async () => { + claimed = this.pendingGoalTurnBySession.get(sessionId) === request; + return claimed; + }, + onTurnStarted: async () => { + if (this.pendingGoalTurnBySession.get(sessionId) !== request) { + await this.handleTurnError(sessionId, sessionDoc); + return false; + } + this.pendingGoalTurnBySession.delete(sessionId); + return true; + }, + } ); + return claimed; } async steerSession(options: { @@ -5287,6 +5305,7 @@ export class SessionExecutionService { return { success: true }; } + this.pendingGoalTurnBySession.delete(sessionId); this.markTurnCancelled(sessionId, turnId); const runtime = this.getTurnRuntime(sessionId, turnId); if (runtime) { diff --git a/apps/cli/tests/agent-client-session-info.test.ts b/apps/cli/tests/agent-client-session-info.test.ts index fdfca0e2d..b29c6ba27 100644 --- a/apps/cli/tests/agent-client-session-info.test.ts +++ b/apps/cli/tests/agent-client-session-info.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import type { ACPSessionId, SessionId } from '@lody/shared'; -import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { PromptRequest, SessionNotification } from '@agentclientprotocol/sdk'; import { AgentClient } from '../src/agent/agent-client'; import type { Logger } from '../src/utils/logger'; @@ -41,6 +41,50 @@ const sessionInfoNotification = (update: Record): SessionNotifi update: { sessionUpdate: 'session_info_update', ...update }, }) as unknown as SessionNotification; +describe('AgentClient goal prompt transport', () => { + it.each(['pause', 'clear'] as const)( + 'sends cold %s through the advertised prompt transport', + async (action) => { + const { client } = createTestClient('codex'); + const requests: PromptRequest[] = []; + Object.assign(client, { + lodyExtensionCapabilities: { + goal: { + version: 1, + actions: ['set', 'pause', 'resume', 'clear'], + controlActions: ['pause', 'clear'], + promptActions: ['set', 'pause', 'resume', 'clear'], + }, + }, + connection: { + prompt: async (request: PromptRequest) => { + requests.push(request); + return { stopReason: 'end_turn' as const }; + }, + }, + }); + expect(client.resolveGoalActionTransport(action)).toBe('request'); + await expect( + client.prompt('acp-test' as ACPSessionId, [], { goalControl: { action } }) + ).resolves.toEqual({ stopReason: 'end_turn' }); + expect(requests).toEqual([ + { + sessionId: 'acp-test', + prompt: [], + _meta: { lody: { goalControl: { version: 1, action } } }, + }, + ]); + + // Manual commands are still ordinary prompts, not rewritten as button metadata. + await client.prompt('acp-test' as ACPSessionId, [{ type: 'text', text: '/goal Ship it' }]); + expect(requests[1]).toEqual({ + sessionId: 'acp-test', + prompt: [{ type: 'text', text: '/goal Ship it' }], + }); + } + ); +}); + describe('AgentClient session title updates', () => { it('forwards Claude session_info_update titles', async () => { const { client, onUpdateMessage, onSessionTitleUpdate } = createTestClient('claude'); diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 3cf7dd703..94531cf81 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -6897,29 +6897,78 @@ describe('SessionExecutionService goal control', () => { }: { transport?: 'request' | 'promptMeta' | 'slashCommand' | null; } = {}) => { - const controlGoal = vi.fn(async () => {}); + const submitted = createDeferred(); + const completion = createDeferred(); + const failures: string[] = []; + const failed = createDeferred(); + const delivered: Array = []; + let goalStatus = 'active'; + const controlGoal = async (action: string) => { + goalStatus = action; + }; const agentClient = { resolveGoalActionTransport: () => transport, controlGoal, + isCreated: () => true, + prompt: async (_id: unknown, _blocks: unknown, options: unknown) => { + delivered.push(options); + submitted.resolve(); + await completion.promise; + }, + cancel: async () => { + completion.resolve(); + }, + }; + const session = { + agentClient, + acpSessionId: 'acp-goal', + getWorkdir: () => '/tmp', + getHostWorkdir: () => '/tmp', + getParentSessionId: () => undefined, + exec: async () => '', + updateGitIdentity: () => {}, + applyExecutionPlaneLimits: async () => {}, + }; + const sessionDoc = { + getMetaState: async () => ({ + id: goalSessionId, + cliType: 'builtin', + agentType: 'codex', + acpSessionId: 'acp-goal', + }), + getHistory: async () => [], + setStatus: async () => {}, + setLastMessageAt: async () => {}, + updateHistory: async () => {}, }; const deps = createBaseDeps({ sessionManager: { - getSession: () => ({ agentClient }), + getSession: () => session, getPendingSession: () => null, } as unknown as SessionManager, workspaceDocument: { - getOrCreateSessionDoc: async () => ({ - getMetaState: async () => ({ - id: goalSessionId, - cliType: 'builtin', - agentType: 'codex', - acpSessionId: 'acp-goal', - }), - }), + repo: { upsertDocMeta: async () => {}, getDocMeta: async () => undefined }, + getOrCreateSessionDoc: async () => sessionDoc, + getOrOpenSessionCode: async () => null, } as unknown as LoroDocumentManager, + recordChatFailure: async (_doc, reason, message) => { + failures.push(message ?? reason); + failed.resolve(); + }, }); const service = new SessionExecutionService(deps); - return { service, controlGoal }; + return { + service, + deps, + agentClient, + sessionDoc, + submitted, + completion, + delivered, + failures, + failed, + goalStatus: () => goalStatus, + }; }; const goalArgs = { @@ -6930,51 +6979,47 @@ describe('SessionExecutionService goal control', () => { } as const; it('pauses out-of-band without opening a turn', async () => { - const { service, controlGoal } = createGoalService({ transport: 'request' }); - const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const { service, goalStatus } = createGoalService({ transport: 'request' }); const response = await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); expect(response).toMatchObject({ accepted: true, disposition: 'applied', action: 'pause' }); - expect(controlGoal).toHaveBeenCalledWith('pause'); + expect(goalStatus()).toBe('pause'); // The goal's own prompt owns the session's only turn slot; a pause that // needed a free slot could never reach the goal it is stopping. - expect(continueSession).not.toHaveBeenCalled(); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); it('refuses an action the agent never advertised', async () => { const { service } = createGoalService({ transport: null }); - const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); expect(response).toMatchObject({ accepted: false, disposition: 'unsupported' }); - expect(continueSession).not.toHaveBeenCalled(); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); it('starts a Lody-owned turn for an action that resumes work', async () => { - const { service } = createGoalService({ transport: 'promptMeta' }); - const continueSession = vi.spyOn(service, 'continueSession').mockResolvedValue(undefined); + const { service, submitted, completion, delivered, failures } = createGoalService({ + transport: 'promptMeta', + }); const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); - expect(response).toMatchObject({ accepted: true, disposition: 'turn_started' }); - expect(continueSession).toHaveBeenCalledTimes(1); - const [request, options] = continueSession.mock.calls[0]!; - expect(options).toMatchObject({ - dispatchSource: 'goal', - goalControl: { action: 'resume' }, - }); - // Resuming a goal must not select a new model or mode. - expect(request.acpSessionConfig.modelId).toBeUndefined(); - expect(request.acpSessionConfig.modeId).toBeUndefined(); + expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + expect(failures).toEqual([]); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(true); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + completion.resolve(); + await released; + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); it('waits for the running turn to release instead of dropping the resume', async () => { - const { service } = createGoalService({ transport: 'promptMeta' }); - const continuationStarted = createDeferred(); - const continueSession = vi.spyOn(service, 'continueSession').mockImplementation(async () => { - continuationStarted.resolve(); + const { service, submitted, completion, delivered } = createGoalService({ + transport: 'promptMeta', }); const internals = service as unknown as { currentTurnBySession: Map; @@ -6985,20 +7030,167 @@ describe('SessionExecutionService goal control', () => { const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); - expect(continueSession).not.toHaveBeenCalled(); + expect(delivered).toEqual([]); internals.clearCurrentTurn(goalSessionId, 'draining-turn'); - await continuationStarted.promise; + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + completion.resolve(); + await released; + }); + + it('retains an accepted goal across more than three competing turns', async () => { + const { service, submitted, completion, delivered } = createGoalService({ + transport: 'promptMeta', + }); + const internals = service as unknown as { + currentTurnBySession: Map; + clearCurrentTurn: (sessionId: SessionId, turnId: string) => void; + }; + let waiting = createDeferred(); + const originalWait = service.waitForTurnRelease.bind(service); + vi.spyOn(service, 'waitForTurnRelease').mockImplementation((sessionId, turnId) => { + const result = originalWait(sessionId, turnId); + waiting.resolve(); + return result; + }); + internals.currentTurnBySession.set(goalSessionId, 'busy-0'); + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + for (let index = 0; index < 4; index += 1) { + await waiting.promise; + waiting = createDeferred(); + internals.clearCurrentTurn(goalSessionId, `busy-${index}`); + internals.currentTurnBySession.set(goalSessionId, `busy-${index + 1}`); + } + await waiting.promise; + expect(delivered).toEqual([]); + internals.clearCurrentTurn(goalSessionId, 'busy-4'); + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + const released = originalWait(goalSessionId, 'turn-1'); + completion.resolve(); + await released; + }); - expect(continueSession).toHaveBeenCalledTimes(1); - expect(continueSession.mock.calls[0]![1]).toMatchObject({ dispatchSource: 'goal' }); + it.each(['pause', 'clear'] as const)( + 'a newer %s supersedes resume while metadata is loading', + async (action) => { + const { service, agentClient, sessionDoc, delivered, goalStatus } = createGoalService({ + transport: 'promptMeta', + }); + const metadataRead = createDeferred(); + const metadataReady = createDeferred(); + const originalMeta = sessionDoc.getMetaState; + sessionDoc.getMetaState = async () => { + metadataRead.resolve(); + await metadataReady.promise; + return originalMeta(); + }; + const internals = service as unknown as { + startGoalTurn: (request: unknown) => Promise; + }; + const originalStart = internals.startGoalTurn.bind(service); + const settled = createDeferred(); + vi.spyOn(internals, 'startGoalTurn').mockImplementation(async (request) => { + try { + return await originalStart(request); + } finally { + settled.resolve(); + } + }); + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + await metadataRead.promise; + agentClient.resolveGoalActionTransport = () => 'request'; + await service.controlSessionGoal({ ...goalArgs, action }); + metadataReady.resolve(); + await settled.promise; + expect(goalStatus()).toBe(action); + expect(delivered).toEqual([]); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); + } + ); + + it.each([false, true])( + 'only a matching Stop invalidates queued goal work (stale=%s)', + async (stale) => { + const { service, delivered, submitted, completion } = createGoalService({ + transport: 'promptMeta', + }); + const internals = service as unknown as { + currentTurnBySession: Map; + pendingGoalTurnBySession: Map; + clearCurrentTurn: (sessionId: SessionId, turnId: string) => void; + }; + internals.currentTurnBySession.set(goalSessionId, 'draining'); + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + await service.cancelSession({ + type: 'session/cancel', + sessionId: goalSessionId, + machineId: 'machine-1' as MachineId, + workspaceId: 'workspace-1' as WorkspaceId, + turnId: stale ? 'older' : 'draining', + }); + expect(internals.pendingGoalTurnBySession.has(goalSessionId)).toBe(stale); + internals.clearCurrentTurn(goalSessionId, 'draining'); + if (stale) { + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + completion.resolve(); + await released; + } else { + expect(delivered).toEqual([]); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); + } + } + ); + + it('fences a superseded resume after turn ownership but before provider submission', async () => { + const { service, deps, agentClient, delivered, goalStatus } = createGoalService({ + transport: 'promptMeta', + }); + const preparing = createDeferred(); + const ready = createDeferred(); + deps.applyAcpModeAndModel = async (_session, config) => { + expect(config.modelId).toBeUndefined(); + expect(config.modeId).toBeUndefined(); + preparing.resolve(); + await ready.promise; + }; + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + await preparing.promise; + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(true); + agentClient.resolveGoalActionTransport = () => 'request'; + await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + ready.resolve(); + await released; + expect(goalStatus()).toBe('pause'); + expect(delivered).toEqual([]); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); + }); + + it('records a visible failure when an accepted action cannot load its configuration', async () => { + const { service, sessionDoc, failed, failures, delivered } = createGoalService({ + transport: 'promptMeta', + }); + sessionDoc.getMetaState = async () => { + throw new Error('Synthetic metadata failure'); + }; + expect(await service.controlSessionGoal({ ...goalArgs, action: 'resume' })).toMatchObject({ + accepted: true, + disposition: 'queued', + }); + await failed.promise; + expect(failures).toEqual(['Goal resume failed: Synthetic metadata failure']); + expect(delivered).toEqual([]); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); it('keeps only the newest queued action so a stale pause cannot undo a resume', async () => { - const { service } = createGoalService({ transport: 'promptMeta' }); - const continuationStarted = createDeferred(); - const continueSession = vi.spyOn(service, 'continueSession').mockImplementation(async () => { - continuationStarted.resolve(); + const { service, submitted, completion, delivered } = createGoalService({ + transport: 'promptMeta', }); const internals = service as unknown as { currentTurnBySession: Map; @@ -7010,11 +7202,10 @@ describe('SessionExecutionService goal control', () => { await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); internals.clearCurrentTurn(goalSessionId, 'draining-turn'); - await continuationStarted.promise; - - expect(continueSession).toHaveBeenCalledTimes(1); - expect(continueSession.mock.calls[0]![1]).toMatchObject({ - goalControl: { action: 'resume' }, - }); + await submitted.promise; + expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); + const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + completion.resolve(); + await released; }); }); diff --git a/specs/session-goal-control.md b/specs/session-goal-control.md index 64b1e6b30..8e35e54ec 100644 --- a/specs/session-goal-control.md +++ b/specs/session-goal-control.md @@ -49,21 +49,32 @@ applies the action, adopts any turn it started natively, and keeps that prompt open for the goal's remaining turns. The action never appears as user-visible command text. +This is the button transport, not a replacement for typed commands. Users can +still enter `/goal xxx` (and the supported `/goal` subcommands) through the +ordinary chat path. Both entrances use the agent's same goal state and scheduler. + An agent advertises which actions it supports and which transport carries each. Lody offers exactly the advertised actions, for any agent — goal control is not Codex-specific product behavior, only Codex-first availability. ## Guarantees +- An active goal never suppresses turn completion or its notification. - A published goal status is authoritative for what the goal will do next. It is not a statement about whether a turn is running right now: a paused goal may still be draining its final turn. - `pause` and `clear` are delivered without waiting for a turn. If the session - has no live agent, they fall back to the turn path, which starts the agent. + has no live agent, they fall back to the turn path, which starts the agent and + uses its advertised prompt transport even if it also advertises requests. - `resume` and `set` never run concurrently with another turn. When one is running, the action waits for it and then executes; it is not dropped and does not require the user to stop the session first. Only the newest queued action - survives, so a stale pause cannot undo a later resume. + survives, so a stale pause cannot undo a later resume. A newer out-of-band + Pause/Clear or a valid Stop also discards any goal action not yet submitted. +- Work-starting button requests acknowledge acceptance as `queued`, without + waiting for the persistent prompt to finish or claiming that execution already + started. Startup failures appear through the session's normal failure history; + an accepted action must not disappear merely because several turns ran first. - A goal turn creates an assistant entry and no user message. It carries no run configuration, so resuming a goal cannot silently change model or mode. - Stopping a session cancels the turn first and then pauses the goal, so Stop @@ -90,5 +101,8 @@ Inspected implementation: `packages/acp-extension-core` (`LodyGoalCapability`, Executed validation: adapter tests for the prompt-metadata transport, CLI tests for transport selection and for queueing a resume behind a draining turn, and -component tests for capability-driven command availability. No live Codex goal +component tests for capability-driven command availability. Host regressions cover +prompt-lifetime-independent acceptance, more than three competing turns, +supersession before provider submission, and visible startup failures. Agent-client +wire tests cover cold pause/clear and unchanged manual `/goal xxx` prompts. No live Codex goal was exercised end to end. From aeccbd3c20731aaecbb568b8063aa8cf3d1d6ec8 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 15:32:43 +0000 Subject: [PATCH 8/9] chore: simplify goal control guards and regression coverage Remove redundant stale-request validation, duplicate queue lookups, unused acceptance handling, and single-use constant aliases. Consolidate overlapping tests while preserving the observable pre-submission fence regression and document the ablation results. Validation: 115 focused tests passed. Pre-commit workspace typecheck, lint, formatting, and docs checks passed. Full pnpm check encountered component act-is-not-a-function failures and timed out during tests after five minutes (exit 124). Model: gpt-5 --- .../2026-09-09-goal-control-plane.md | 22 ++++++++++ apps/cli/src/agent/agent-client.ts | 3 +- apps/cli/src/agent/goal-control.test.ts | 12 ------ apps/cli/src/agent/goal-control.ts | 10 +---- .../src/session/session-execution-service.ts | 14 +++---- .../tests/session-execution-service.test.ts | 42 +++++++------------ 6 files changed, 45 insertions(+), 58 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md index 14cdf5d7b..3c46804e3 100644 --- a/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -70,6 +70,28 @@ The UI now reads `goalActions` from the ACP capability cache instead of testing `agentType === 'codex'`, and its pending state expires after a minute so a slow action cannot leave the banner dead. +## Host simplification after the correction + +The follow-up removes duplicate work without changing the goal contract. The +original 117 host tests passed after the production-code ablations; removing two +subsumed tests leaves 115 passing tests. The remaining fences are not speculative: +removing the pre-submission fence delivers an old Resume after Pause. Its test +now races explicit submission/release signals and reports `submitted` instead of +waiting for a timeout. No new queue abstraction or authentication mechanism is added. + +| Ablation | Evidence and decision | +| ------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | +| Metadata-read stale-request guard | Removed; the existing claim fence still rejects it, including both Pause/Clear tests. | +| Queue `has` followed by `get` | Replaced with one lookup per iteration; all ownership tests pass. | +| Local `turn_started` acceptance case | Removed: this handler returns only applied/queued/unsupported/error; the wire response union is unchanged. | +| Goal method alias and single-use fallback-text export | Removed; use the Core method constant and inline the unchanged fallback at its sole consumer. | +| One-turn queue test and status-transport selector test | Removed; multi-turn queue and actual AgentClient wire tests cover their assertions. | +| Pre-submission fence | Kept: removal fails the observable submission/release test; restoring it passes. | + +This is a bounded simplification of the host correction, not evidence that the +whole protocol has been exhaustively minimized. Claim tracking, pending-action +supersession, and startup failure reporting retain their existing responsibilities. + ## Alternatives **Let the adapter start the resumed goal's turn itself.** The adapter already has diff --git a/apps/cli/src/agent/agent-client.ts b/apps/cli/src/agent/agent-client.ts index ca4521fc8..36a438d3a 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -87,7 +87,6 @@ import { buildGoalPromptMeta, buildGoalSlashCommandText, resolveGoalActionTransport, - GOAL_CONTROL_METHOD, type GoalActionTransport, type GoalPromptControl, } from './goal-control'; @@ -1401,7 +1400,7 @@ export class AgentClient implements acp.Client { throw new Error('[ACP_GOAL_UNAVAILABLE] ACP session is not connected'); } const response = await connection.request( - GOAL_CONTROL_METHOD, + LODY_EXTENSION_METHODS.sessionGoal, { sessionId, action } ); const parsed = z diff --git a/apps/cli/src/agent/goal-control.test.ts b/apps/cli/src/agent/goal-control.test.ts index f40f6dff2..8b9b86972 100644 --- a/apps/cli/src/agent/goal-control.test.ts +++ b/apps/cli/src/agent/goal-control.test.ts @@ -13,18 +13,6 @@ const capability = (overrides: Partial = {}): LodyGoalCapabi }); describe('resolveGoalActionTransport', () => { - it('sends status-only actions out-of-band when the agent advertises them', () => { - const advertised = capability({ - controlActions: ['pause', 'clear'], - promptActions: ['set', 'pause', 'resume', 'clear'], - }); - - expect(resolveGoalActionTransport(advertised, 'pause')).toBe('request'); - expect(resolveGoalActionTransport(advertised, 'clear')).toBe('request'); - expect(resolveGoalActionTransport(advertised, 'pause', 'prompt')).toBe('promptMeta'); - expect(resolveGoalActionTransport(advertised, 'clear', 'prompt')).toBe('promptMeta'); - }); - it('keeps work-starting actions inside a prompt even when the request lists them', () => { // An agent that starts the goal's work from a bare request would produce // turns Lody never prompted for and cannot attribute to a conversation. diff --git a/apps/cli/src/agent/goal-control.ts b/apps/cli/src/agent/goal-control.ts index 0d749c0b3..750b23bbc 100644 --- a/apps/cli/src/agent/goal-control.ts +++ b/apps/cli/src/agent/goal-control.ts @@ -1,15 +1,7 @@ import type * as acp from '@agentclientprotocol/sdk'; -import { LODY_EXTENSION_METHODS, type LodyGoalCapability } from 'acp-extension-core'; +import type { LodyGoalCapability } from 'acp-extension-core'; import type { SessionGoalAction } from '@lody/shared'; -export const GOAL_CONTROL_METHOD = LODY_EXTENSION_METHODS.sessionGoal; - -/** - * Fallback blocks for a prompt whose goal action started no native turn. The - * agent replaces them when it schedules the continuation itself. - */ -export const GOAL_CONTINUATION_PROMPT_TEXT = 'Continue working toward the active goal.'; - /** * How a goal action can reach the agent. * diff --git a/apps/cli/src/session/session-execution-service.ts b/apps/cli/src/session/session-execution-service.ts index 9263b09b7..4298cac72 100644 --- a/apps/cli/src/session/session-execution-service.ts +++ b/apps/cli/src/session/session-execution-service.ts @@ -91,7 +91,7 @@ import { } from '@/agent/managed-agent-runtime'; import type { FetchAcpCapabilitiesOptions } from '@/agent/acp-capabilities'; import { AcpAuthenticationRequiredError, AgentSteerNotDeliveredError } from '@/agent/agent-client'; -import { GOAL_CONTINUATION_PROMPT_TEXT, type GoalPromptControl } from '@/agent/goal-control'; +import type { GoalPromptControl } from '@/agent/goal-control'; import { AcpAuthenticationManager, type AcpAuthenticationProgressEvent, @@ -1233,8 +1233,7 @@ export class SessionExecutionService { type: 'session/goal_response', sessionId, action, - accepted: - disposition === 'applied' || disposition === 'turn_started' || disposition === 'queued', + accepted: disposition === 'applied' || disposition === 'queued', disposition, ...(error ? { error } : {}), }); @@ -1293,14 +1292,14 @@ export class SessionExecutionService { } this.goalTurnWaiterSessions.add(sessionId); void (async () => { - while (this.pendingGoalTurnBySession.has(sessionId)) { + for (;;) { + const pending = this.pendingGoalTurnBySession.get(sessionId); + if (!pending) return; const snapshot = this.getExecutionSnapshot(sessionId); if (snapshot.hasActiveTurn && snapshot.activeTurnId) { await this.waitForTurnRelease(sessionId, snapshot.activeTurnId); continue; } - const pending = this.pendingGoalTurnBySession.get(sessionId); - if (!pending) return; try { const claimed = await this.startGoalTurn(pending); if (this.pendingGoalTurnBySession.get(sessionId) !== pending) continue; @@ -1339,7 +1338,6 @@ export class SessionExecutionService { const { sessionId, control } = request; const sessionDoc = await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId); const meta = await sessionDoc.getMetaState(); - if (this.pendingGoalTurnBySession.get(sessionId) !== request) return false; if (!meta) { throw new Error(`Session ${sessionId} has no metadata`); } @@ -1362,7 +1360,7 @@ export class SessionExecutionService { // Fallback blocks only: the agent replaces them when the action // schedules its own continuation. The run configuration is // deliberately absent so a goal turn cannot change model or mode. - prompt: GOAL_CONTINUATION_PROMPT_TEXT, + prompt: 'Continue working toward the active goal.', cliType: meta.cliType, agentType: meta.agentType, ...(resumeAcpSessionId ? { resume: resumeAcpSessionId } : {}), diff --git a/apps/cli/tests/session-execution-service.test.ts b/apps/cli/tests/session-execution-service.test.ts index 94531cf81..0433120c2 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -7017,29 +7017,6 @@ describe('SessionExecutionService goal control', () => { expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); }); - it('waits for the running turn to release instead of dropping the resume', async () => { - const { service, submitted, completion, delivered } = createGoalService({ - transport: 'promptMeta', - }); - const internals = service as unknown as { - currentTurnBySession: Map; - clearCurrentTurn: (sessionId: SessionId, turnId?: string) => void; - }; - internals.currentTurnBySession.set(goalSessionId, 'draining-turn'); - - const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); - - expect(response).toMatchObject({ accepted: true, disposition: 'queued' }); - expect(delivered).toEqual([]); - - internals.clearCurrentTurn(goalSessionId, 'draining-turn'); - await submitted.promise; - expect(delivered).toEqual([expect.objectContaining({ goalControl: { action: 'resume' } })]); - const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); - completion.resolve(); - await released; - }); - it('retains an accepted goal across more than three competing turns', async () => { const { service, submitted, completion, delivered } = createGoalService({ transport: 'promptMeta', @@ -7056,7 +7033,10 @@ describe('SessionExecutionService goal control', () => { return result; }); internals.currentTurnBySession.set(goalSessionId, 'busy-0'); - await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + expect(await service.controlSessionGoal({ ...goalArgs, action: 'resume' })).toMatchObject({ + accepted: true, + disposition: 'queued', + }); for (let index = 0; index < 4; index += 1) { await waiting.promise; waiting = createDeferred(); @@ -7147,9 +7127,10 @@ describe('SessionExecutionService goal control', () => { ); it('fences a superseded resume after turn ownership but before provider submission', async () => { - const { service, deps, agentClient, delivered, goalStatus } = createGoalService({ - transport: 'promptMeta', - }); + const { service, deps, agentClient, delivered, goalStatus, submitted, completion } = + createGoalService({ + transport: 'promptMeta', + }); const preparing = createDeferred(); const ready = createDeferred(); deps.applyAcpModeAndModel = async (_session, config) => { @@ -7164,8 +7145,15 @@ describe('SessionExecutionService goal control', () => { agentClient.resolveGoalActionTransport = () => 'request'; await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); const released = service.waitForTurnRelease(goalSessionId, 'turn-1'); + const outcome = Promise.race([ + submitted.promise.then(() => 'submitted'), + released.then(() => 'released'), + ]); ready.resolve(); + const first = await outcome; + completion.resolve(); await released; + expect(first).toBe('released'); expect(goalStatus()).toBe('pause'); expect(delivered).toEqual([]); expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); From ee4b4a647a6aabe2ecab46c27b89f90063ff6fa1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Thu, 10 Sep 2026 23:47:20 +0000 Subject: [PATCH 9/9] test: complete PR policy fixture for original prompt validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI merge includes main’s required Original user prompt section, but the shared validBody fixture omitted it. Add synthetic prompt content without weakening validation or changing policy behavior. Verified the exact CI merge: 102 passed / 2 failed before, 104 passed after. PR-head policy suite: 101 passed. Format check, docs, workspace typecheck, lint and boundary checks passed. Full pnpm check hit component act errors and the five-minute test limit (exit 124). Model: gpt-5 --- .github/scripts/pr-policy.test.mjs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/scripts/pr-policy.test.mjs b/.github/scripts/pr-policy.test.mjs index f43714540..c48ffbbb6 100644 --- a/.github/scripts/pr-policy.test.mjs +++ b/.github/scripts/pr-policy.test.mjs @@ -53,6 +53,12 @@ Run policy tests. - **Deliberately not done or tested:** No live API writes. - **Unknowns / confidence:** Policy behavior is deterministic. +### Original user prompt + +\`\`\`text +Simplify PR policy while preserving bypass and expiry behavior. +\`\`\` + `;