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..3c46804e3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-09-goal-control-plane.md @@ -0,0 +1,198 @@ +# 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 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 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 +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 +`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 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 + +`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.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. + +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 submodule changes have merged; these host corrections need no new release. +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`. + +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. 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 +also includes main's worktree-project contract (`2812417`), while Codex stays on +merged PR #39 (`33d897b`). Choosing either old Core pointer alone would drop a +required contract. Core build/typecheck and Codex typecheck pass with the combined +contract; CLI ownership/goal tests (122), Codex goal/fork/worktree tests (52), shared +capability/config tests (26), and goal UI helper tests (5) pass. This resolves the +dependency mismatch, not the previously recorded host-side P1 findings. The Core +goal branch still needs its own merge/release before registry-only consumption. +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; host-side findings are addressed separately by +the correction recorded in this note. 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/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 d0934da7e..165fabb48 100644 --- a/apps/cli/src/agent/agent-client.ts +++ b/apps/cli/src/agent/agent-client.ts @@ -11,6 +11,7 @@ import { LODY_TOOL_NAMES, type LodyExtensionCapabilities, type LodyElicitationMeta, + type LodyGoalCapability, type LodySubagentTask, type RateLimit, type RateLimitsGetRequest, @@ -24,6 +25,7 @@ import { type AcpConfigOptionValue, type AcpSessionNotification, type AgentConfigCliType, + type SessionGoalAction, type SessionGoalContent, type SessionTurnInputConfig, sanitizeGoalObjective, @@ -81,6 +83,13 @@ import { parseLodyExtensionMessage, parseRateLimitsSnapshot, } from './lody-acp-extension'; +import { + buildGoalPromptMeta, + buildGoalSlashCommandText, + resolveGoalActionTransport, + type GoalActionTransport, + type GoalPromptControl, +} from './goal-control'; /** * Checks if an error is a transport-related error that may be transient. @@ -1362,6 +1371,81 @@ 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( + LODY_EXTENSION_METHODS.sessionGoal, + { 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'] } { + // 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) }; + } + 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 @@ -2397,12 +2481,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})` @@ -2416,10 +2516,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..8b9b86972 --- /dev/null +++ b/apps/cli/src/agent/goal-control.test.ts @@ -0,0 +1,66 @@ +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('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({ controlActions: ['pause'] }), 'pause', 'prompt') + ).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..750b23bbc --- /dev/null +++ b/apps/cli/src/agent/goal-control.ts @@ -0,0 +1,69 @@ +import type * as acp from '@agentclientprotocol/sdk'; +import type { LodyGoalCapability } from 'acp-extension-core'; +import type { SessionGoalAction } from '@lody/shared'; + +/** + * 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. Inside an + * already-owned prompt (including cold restore), select only a prompt transport. + */ +export function resolveGoalActionTransport( + capability: LodyGoalCapability | undefined, + action: SessionGoalAction, + context: 'control' | 'prompt' = 'control' +): GoalActionTransport | null { + if (!capability?.actions.includes(action)) { + return null; + } + if ( + context === 'control' && + capability.controlActions?.includes(action) && + (action === 'pause' || action === 'clear') + ) { + 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 55f01aa5b..6f2acbdf4 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 dc7367f87..68168f5d0 100644 --- a/apps/cli/src/lib/loro/doc.ts +++ b/apps/cli/src/lib/loro/doc.ts @@ -53,6 +53,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 ); } @@ -3108,6 +3111,7 @@ export class MachineDocument implements LoroDocument<{}, MachineMeta> { sourceVersion: string, modelReasoningEfforts?: Record, acknowledgedSteer = false, + goalActions?: SessionGoalAction[], options: { signal?: AbortSignal } = {} ): Promise { options.signal?.throwIfAborted(); @@ -3133,6 +3137,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 c887349c3..3fc61500e 100644 --- a/apps/cli/src/lib/message-handler.ts +++ b/apps/cli/src/lib/message-handler.ts @@ -134,6 +134,8 @@ import { getServerNow, CODE_COLLAB_V2_TEXT_LIMITS, isSessionGoalActive, + type SessionGoalAction, + type SessionGoalResponse, resolveLatestSessionGoalFromHistory, resolveProjectGitHubRepo, type RepoId, @@ -2763,6 +2765,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, @@ -3370,6 +3402,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), @@ -6694,6 +6727,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': 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 832213623..7783c8433 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 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 { @@ -236,6 +243,14 @@ type PromptHandoffRun = { signalSuccessor: () => void; }; +type SessionGoalTurnRequest = { + sessionId: SessionId; + control: GoalPromptControl; + userId: string; + userName: string; + userEmail: string; +}; + type TurnInvocation = { /** Causal input Turn for authorization and durable provenance. */ sourceTurnId: string; @@ -249,6 +264,7 @@ type TurnRuntimeState = { turnId: string; userTurnId?: string; invocation?: TurnInvocation; + goalControl?: GoalPromptControl; session?: ISession; project?: ProjectRef; baseCommitHash?: string | null; @@ -352,6 +368,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; /** @@ -371,10 +389,12 @@ 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'; 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 @@ -579,6 +599,7 @@ export type SessionExecutionServiceDeps = { availableCommands?: AcpCommandSummary[]; sessionFork: boolean; acknowledgedSteer: boolean; + goalActions?: SessionGoalAction[]; modelReasoningEfforts?: Record; capabilitySourceVersion?: string; }>; @@ -719,6 +740,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 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. @@ -1179,6 +1203,191 @@ 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 === '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') { + // A later Pause/Clear supersedes work that has not reached the provider. + this.pendingGoalTurnBySession.delete(sessionId); + 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, + }; + // 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'); + } + + /** + * 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.goalTurnWaiterSessions.has(sessionId)) { + return; + } + this.goalTurnWaiterSessions.add(sessionId); + void (async () => { + 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; + } + 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)}` + ); + } + } + })() + .catch((error: unknown) => { + this.deps.logger.error( + `[${sessionId}] Failed to report queued goal failure: ${formatErrorMessage(error)}` + ); + }) + .finally(() => { + this.goalTurnWaiterSessions.delete(sessionId); + const pending = this.pendingGoalTurnBySession.get(sessionId); + if (pending) this.queueGoalTurn(pending); + }); + } + + 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); + let claimed = false; + 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: 'Continue working toward the active goal.', + 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, + 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: { sessionId: SessionId; expectedTurnId: string; @@ -1617,7 +1826,7 @@ export class SessionExecutionService { private createTurnRuntime( options: Pick< VisibleSessionTurnOptions, - 'sessionId' | 'session' | 'userTurnId' | 'invocation' | 'onTurnSettled' + 'sessionId' | 'session' | 'userTurnId' | 'invocation' | 'onTurnSettled' | 'goalControl' > & { turnId: string } ): TurnRuntimeState { return { @@ -1625,6 +1834,7 @@ export class SessionExecutionService { turnId: options.turnId, userTurnId: options.userTurnId, invocation: options.invocation, + goalControl: options.goalControl, session: options.session, promptStarted: false, promptInFlight: false, @@ -2966,6 +3176,9 @@ export class SessionExecutionService { turnId: runtime.turnId, promptPromise: agentClient.prompt(acpSessionId, promptBlocks, { signal, + ...(runtime.goalControl + ? { goalControl: runtime.goalControl } + : {}), }), }); await self.awaitPromptHandoffTail(runtime, initialRun); @@ -3647,8 +3860,11 @@ export class SessionExecutionService { prepareOptions?: { sessionDoc?: SessionDocument } ): Promise { const { sessionId, acpSessionConfig, userId, userName, userEmail, userTurnId } = message; + // System-caused turns own an assistant entry, not a user dispatch pointer. const executionUserTurnId = - dispatchOptions?.dispatchSource === 'delivery' ? undefined : userTurnId; + dispatchOptions?.dispatchSource === 'delivery' || dispatchOptions?.dispatchSource === 'goal' + ? undefined + : userTurnId; const sessionDoc = prepareOptions?.sessionDoc ?? (await this.deps.workspaceDocument.getOrCreateSessionDoc(sessionId)); @@ -4411,6 +4627,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 @@ -5094,6 +5311,7 @@ export class SessionExecutionService { return { success: true }; } + this.pendingGoalTurnBySession.delete(sessionId); this.markTurnCancelled(sessionId, turnId); const runtime = this.getTurnRuntime(sessionId, turnId); if (runtime) { @@ -5273,7 +5491,8 @@ export class SessionExecutionService { capabilities.sessionFork, sourceVersion, capabilities.modelReasoningEfforts, - capabilities.acknowledgedSteer + capabilities.acknowledgedSteer, + capabilities.goalActions ); })().catch((error: unknown) => { this.deps.logger.debug( @@ -5582,6 +5801,7 @@ export class SessionExecutionService { availableCommands, sessionFork, acknowledgedSteer, + goalActions, modelReasoningEfforts, capabilitySourceVersion, } = await this.deps.fetchAcpCapabilities( @@ -5622,6 +5842,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 f55dd9ffd..cde8afc6a 100644 --- a/apps/cli/src/session/session.ts +++ b/apps/cli/src/session/session.ts @@ -666,6 +666,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/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 f8a827414..b8f1b9111 100644 --- a/apps/cli/tests/session-execution-service.test.ts +++ b/apps/cli/tests/session-execution-service.test.ts @@ -2550,7 +2550,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 ) ); }); @@ -6659,6 +6661,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( @@ -7015,3 +7019,312 @@ describe('SessionExecutionService', () => { expect(fetchAcpCapabilities).toHaveBeenCalledTimes(2); }); }); + +describe('SessionExecutionService goal control', () => { + const goalSessionId = 'session-goal' as SessionId; + + const createGoalService = ({ + transport = 'request', + }: { + transport?: 'request' | 'promptMeta' | 'slashCommand' | null; + } = {}) => { + 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: () => session, + getPendingSession: () => null, + } as unknown as SessionManager, + workspaceDocument: { + 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, + deps, + agentClient, + sessionDoc, + submitted, + completion, + delivered, + failures, + failed, + goalStatus: () => goalStatus, + }; + }; + + 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, goalStatus } = createGoalService({ transport: 'request' }); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); + + expect(response).toMatchObject({ accepted: true, disposition: 'applied', action: '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(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); + }); + + it('refuses an action the agent never advertised', async () => { + const { service } = createGoalService({ transport: null }); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + expect(response).toMatchObject({ accepted: false, disposition: 'unsupported' }); + expect(service.getExecutionSnapshot(goalSessionId).hasActiveTurn).toBe(false); + }); + + it('starts a Lody-owned turn for an action that resumes work', async () => { + const { service, submitted, completion, delivered, failures } = createGoalService({ + transport: 'promptMeta', + }); + + const response = await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + 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('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'); + 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(); + 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; + }); + + 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, submitted, completion } = + 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'); + 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); + }); + + 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, 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'); + + await service.controlSessionGoal({ ...goalArgs, action: 'pause' }); + await service.controlSessionGoal({ ...goalArgs, action: 'resume' }); + + 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; + }); +}); diff --git a/packages/acp-extension-codex b/packages/acp-extension-codex index e472d56e9..5f0aab0f6 160000 --- a/packages/acp-extension-codex +++ b/packages/acp-extension-codex @@ -1 +1 @@ -Subproject commit e472d56e9a07b782d1c065a631d630765958a6a3 +Subproject commit 5f0aab0f6614ef50dc57c2b137cdc0d4b864359e diff --git a/packages/acp-extension-core b/packages/acp-extension-core index 9c47fecaf..4c8ffe929 160000 --- a/packages/acp-extension-core +++ b/packages/acp-extension-core @@ -1 +1 @@ -Subproject commit 9c47fecaf7216a402029b5db42be85ea622eeee7 +Subproject commit 4c8ffe929148aba5e216371323f5a27801113b34 diff --git a/packages/components/src/atoms/runtime.ts b/packages/components/src/atoms/runtime.ts index 89a846783..1316a03f9 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, @@ -300,6 +302,16 @@ export type WorkspaceRuntime = { }, options?: { timeoutMs?: number } ) => Promise; + requestSessionGoal: ( + machineId: MachineId, + args: { + sessionId: SessionId; + action: SessionGoalAction; + objective?: string; + userId: 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 68aee576f..e6ba2e722 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -211,9 +211,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'; @@ -2381,6 +2380,7 @@ export const SessionChatInterface = memo( markSessionRead, requestSessionCancel, requestSessionDispatch, + requestSessionGoal, requestSessionSteer, touchSessionActivity, transferSessionOwner, @@ -2705,11 +2705,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) { @@ -2741,6 +2744,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 @@ -4184,20 +4200,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) { @@ -4218,7 +4238,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( @@ -5061,6 +5091,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..76a756ac2 100644 --- a/packages/components/src/components/sessions/session-goal-control.ts +++ b/packages/components/src/components/sessions/session-goal-control.ts @@ -1,30 +1,31 @@ -import { SESSION_GOAL_COMMANDS, type SessionGoalCommand } from '@lody/shared'; - -const NO_GOAL_COMMANDS: readonly SessionGoalCommand[] = []; +import { + SESSION_GOAL_COMMANDS, + type AcpCapabilityCacheEntry, + type SessionGoalCommand, +} from '@lody/shared'; /** - * 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 +export const getSessionGoalCommands = ( + capability: Pick | undefined ): readonly SessionGoalCommand[] => - agentType === 'codex' ? SESSION_GOAL_COMMANDS : NO_GOAL_COMMANDS; - -export const canPauseGoalThroughPromptBridge = ( - agentType: string | null | undefined -): boolean => getPromptBridgeGoalCommands(agentType).includes('pause'); + SESSION_GOAL_COMMANDS.filter((command) => capability?.goalActions?.includes(command)); /** - * 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 61ca15212..696610b4d 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, @@ -387,6 +389,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; @@ -910,6 +923,35 @@ 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, + }); + }, + [runtime] + ); + const requestSessionSteer = useCallback( async ( sessionId: SessionId, @@ -1444,6 +1486,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 443dd162d..660886950 100644 --- a/packages/components/src/providers/create-workspace-runtime.ts +++ b/packages/components/src/providers/create-workspace-runtime.ts @@ -1663,6 +1663,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 +1164,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..a2796b076 100644 --- a/packages/components/tests/session-goal-control.test.ts +++ b/packages/components/tests/session-goal-control.test.ts @@ -1,25 +1,27 @@ import { describe, expect, it } from 'vitest'; import { - canPauseGoalThroughPromptBridge, - getPromptBridgeGoalCommands, - GOAL_PROMPT_DISPATCH_OPTIONS, + 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([]); }); - 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', () => { + expect(getSessionGoalCommands({ goalActions: ['set', 'pause', 'resume', 'clear'] })).toEqual([ + 'pause', + 'resume', + 'clear', + ]); }); - 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']); }); it('keeps a quiescent session direct-dispatchable while its goal remains active', () => { @@ -48,8 +50,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..c9013a958 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,18 @@ 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), + }) + .strict(), +}).strict(); + export const LoroSessionSteerRpcRequestSchema = BaseRpcRequestSchema.extend({ method: z.literal('session/steer'), params: z @@ -582,6 +599,7 @@ export const LoroStreamsRpcRequestSchema = z.discriminatedUnion('method', [ LoroSessionCancelRpcRequestSchema, LoroSessionLiveStatusRpcRequestSchema, LoroSessionSteerRpcRequestSchema, + LoroSessionGoalRpcRequestSchema, LoroSessionTerminateRpcRequestSchema, LoroSessionForkRpcRequestSchema, LoroSessionEditAndResendRpcRequestSchema, @@ -1424,6 +1442,7 @@ export type LoroMachineRpcResult = | SessionCancelResponse | LoroSessionLiveStatusRpcResponse | SessionSteerResponse + | SessionGoalResponse | SessionTerminateResponse | SessionForkResponse | SessionEditAndResendResponse @@ -1451,6 +1470,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 +1623,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 +1819,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 +1896,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 +2280,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.forkContext, finalPending.editAndResendContext, finalPending.steerContext, + finalPending.goalContext, finalPending.previewContext, finalPending.localProjectContext, finalPending.dispatchContext, @@ -2276,6 +2313,7 @@ export class LoroStreamsRpcResponseDispatcher { finalPending.forkContext, finalPending.editAndResendContext, finalPending.steerContext, + finalPending.goalContext, finalPending.previewContext, finalPending.localProjectContext, finalPending.dispatchContext, @@ -2651,6 +2689,25 @@ export class LoroStreamsMachineRpcClient { })) as SessionSteerResponse | null; } + async requestSessionGoal(options: { + sessionId: string; + action: SessionGoalAction; + objective?: string; + userId: 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, + }, + })) as SessionGoalResponse | null; + } + async requestSessionTerminate(options: { sessionId: string; timeoutMs?: number; @@ -3120,6 +3177,16 @@ export class LoroStreamsMachineRpcClient { inputConfig: SessionTurnInputConfig; }; } + | { + method: 'session/goal'; + timeoutMs: number; + params: { + sessionId: string; + action: SessionGoalAction; + objective?: string; + userId: string; + }; + } | { method: 'session/terminate'; timeoutMs: number; @@ -3332,6 +3399,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 +3511,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 +3702,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 d224cb46c..ad4be8caa 100644 --- a/packages/shared/AGENTS.md +++ b/packages/shared/AGENTS.md @@ -73,6 +73,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 a1d229f91..294e1e08a 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 { createPlanModeConfigOption } from 'acp-extension-core'; import type { AgentConfigId, AgentRoleId, McpServerId, SessionId } from './ids'; import type { MessageTextSpan } from './message-text-spans'; @@ -343,7 +344,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'; @@ -377,6 +378,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..10af2ab4a 100644 --- a/packages/shared/src/goal.ts +++ b/packages/shared/src/goal.ts @@ -12,6 +12,11 @@ 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]; + 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..d6d3912bb 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,17 @@ 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), + }) + .strict(), + }).strict(), BaseLocalMachineRpcRequestSchema.extend({ method: z.literal('session/preview-endpoint-acquire'), params: z @@ -258,6 +271,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 68f4ef21d..35fc17bb2 100644 --- a/packages/shared/src/message-schemas.ts +++ b/packages/shared/src/message-schemas.ts @@ -10,6 +10,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'; @@ -679,6 +680,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'), @@ -1222,6 +1234,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(), }) @@ -2018,6 +2031,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..8e35e54ec --- /dev/null +++ b/specs/session-goal-control.md @@ -0,0 +1,108 @@ +# 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. + +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 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. 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 + 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. 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.