From f75d9967ec14c186ec7768e119057482479bc18f Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 10:52:44 +0800 Subject: [PATCH 1/5] feat(acp): expose observable Goal and Plan execution Generated-by: Codex --- .../acp-pr8-goal-plan-design.zh-CN.md | 271 ++++ packages/cli/src/__tests__/acp-agent.test.ts | 150 +++ .../src/__tests__/acp-child-process.test.ts | 1 + .../acp-goal-plan-child-process.test.ts | 1097 +++++++++++++++++ .../acp-goal-plan-operations.test.ts | 176 +++ .../acp-session-domain-observation.test.ts | 119 ++ .../src/__tests__/acp-stdio-server.test.ts | 1 + packages/cli/src/acp/README.md | 33 + packages/cli/src/acp/VALIDATION.md | 53 + packages/cli/src/acp/goal-plan-operations.ts | 249 ++++ packages/cli/src/acp/goal-plan-routes.ts | 40 + packages/cli/src/acp/maka-acp-agent.ts | 63 + .../cli/src/acp/session-domain-observation.ts | 206 ++++ packages/cli/src/acp/session-registry.ts | 256 +++- .../cli/src/runtime-host-session-channel.ts | 6 + 15 files changed, 2711 insertions(+), 10 deletions(-) create mode 100644 docs/architecture/acp-pr8-goal-plan-design.zh-CN.md create mode 100644 packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts create mode 100644 packages/cli/src/__tests__/acp-goal-plan-operations.test.ts create mode 100644 packages/cli/src/__tests__/acp-session-domain-observation.test.ts create mode 100644 packages/cli/src/acp/goal-plan-operations.ts create mode 100644 packages/cli/src/acp/goal-plan-routes.ts create mode 100644 packages/cli/src/acp/session-domain-observation.ts diff --git a/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md b/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md new file mode 100644 index 0000000000..a890916cd3 --- /dev/null +++ b/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md @@ -0,0 +1,271 @@ + + +# ACP PR8:Goal/Plan 执行设计与任务拆解 + +- 日期:2026-09-26。状态:设计完成,待实现;本文不代表功能或测试已完成。 +- 需求:[Issue #3132](https://github.com/apache/maka/issues/3132) 及其 [ACP v1 tracker 的 PR8](https://github.com/apache/maka/issues/3132#issuecomment-5386735709)。 +- 本地分析基线:`main@87217d370`,已经包含 PR6(#5621)。核对时官方 main 为 `87fc9f69cd11648f31048c1b633bb813aca5f515`。 +- 实施者:新的 Codex 任务,模型 `gpt-6-sol`,reasoning effort `high`,使用独立 worktree。 +- 本轮只做静态分析、设计和拆解,没有修改实现代码、运行功能测试或发布 GitHub 评论。 + +## 1. 交付边界 + +PR8 交付六个具体 `_maka/` 请求,连接 Host 已有 Goal/Plan 能力,并让客户端在没有新的普通 prompt 时也能接收后台执行的文本、工具、交互和状态。Host 继续独占执行准入、持久化、预算、冲突判断和状态转换。 + +纳入: + +1. `goal.query / goal.arm / goal.control / plan.query / plan.control / plan.turn.start` 的 ACP 路由与输入校验。 +2. 使用 PR6 的 retained attachment、唯一 Turn observer 和 pending interaction 恢复路径,先观察再启动。 +3. Goal 权威投影通知、Plan 变更提示及权威分页查询;重连后的状态刷新。 +4. 准入响应与执行完成分离;重复请求、响应丢失、过期审批、外部控制、取消、close/EOF 的处理。 +5. 官方 SDK → ACP stdio → 真实 Host 的执行验收,以及文档/能力声明。 + +不纳入:PR7 Artifact/Memory、ACP client/Antigravity、ACP v2、steering、新调度器、Plan/Goal 状态机、存储格式变更、通用 CRUD/RPC 框架、全量 PR6 资源所有权重构、Desktop UI 改造。PR8 仅依赖已合并 PR6;不导入 PR7 分支代码。 + +当前未跟踪的 `acp-session-restore-refactor-plan.zh-CN.md` 是独立重构提案,不是已存在的 SessionHandle 实现。PR8 可提取自己实际复用的窄接口,不应顺手完成该提案。 + +## 2. 已核实的事实与设计决定 + +| 当前事实 | PR8 决定 | +| --- | --- | +| `goal.arm` 保存 active Goal,独自调用不会启动 Turn | 保留原语义;以一次用户明确发出的普通 prompt 开始首次工作,此后 Host 自行续跑。不得暗发 prompt,或暗做 pause/resume 来启动 | +| `goal.control.resume` 通过 Host continuation 恢复驱动 | 调用前安装观察和交互客户端;不在 adapter 内轮询并调度下一轮 | +| `plan.control` 是状态转换;普通控制在活跃 root 下可返回 `session_busy` | 原样保留五种 control 输入及 Host 结果;不隐式停止 Turn | +| `plan.turn.start` 在 Host 内结合 approve/resume 与 root admission | 执行按钮直接调用此操作;不能先 `plan.control(approve)` 再 start | +| Plan control 有 `operationId`;Plan start 使用调用者提供的 `turnId`,Host 将它作为控制 operation identity | 原样传递,不以 JSON-RPC request id 替代,不在重试时重新生成 | +| Goal control 需要 `goalId + expectedRevision`;arm 没有幂等请求 ID | 不自动更新 revision 重试;不能承诺 arm 的 exactly-once 或把相同 condition 当作同一请求 | +| Plan query 是 `list_start/list_continue`,每页最多 16 条、结果最多 64 KiB,可能返回 `revision_changed` | 保留分页,禁止 adapter 无界合并所有历史计划或静默重启翻页 | +| `AcpTurnObservation` 已支持非 prompt Turn;`_maka/turn/status` 已有终态通知及能力协商 | 复用,不增加第二个 reader,不伪造 prompt response,也不改现有终态枚举 | +| Session channel 已有 `onGoalChanged`、`onSessionDomainChanged`、snapshot/recovery callback | 从原订阅刷新状态,不新增仅为状态刷新而开的 subscription | + +关键代码依据:`packages/runtime-host/src/protocol/{goal,plan}.ts`、`server/{goal,plan}-coordinator.ts`、`packages/runtime/src/goal-continuation.ts`、`packages/cli/src/acp/{maka-acp-agent,session-registry,turn-observation}.ts`、`packages/cli/src/runtime-host-session-channel.ts`。 + +首次 Goal 流程明确为 `new/load → goal.arm → 用户的 session/prompt → Host continuation → goal.control`。验收“无需再发 prompt”指启动后的后台续跑、显式 Goal resume 和 Plan start,不改变 arm 的产品含义。 + +Plan 的“恢复”区分两种:`plan.turn.start(resume_execution)` 恢复计划执行;既有 `_maka/turn/resume` 恢复中断 Runtime Turn,仍受其安全计划约束。不可互相替代或级联调用。当前 Plan 状态是否可恢复由 Host 决定,不保证已取消计划可以恢复。 + +## 3. 模块结构与依赖 + +保持现有平铺目录,建议新增三个有实际消费者的模块;命名可随实现微调,责任不可合并回 Registry。 + +| 模块 | 责任 | 明确不拥有 | +| --- | --- | --- | +| `goal-plan-routes.ts` | 注册六个具体方法、复用 Host decoder、构造客户端上下文、声明能力 | Host 连接、Session Map、业务状态 | +| `goal-plan-operations.ts` | 六个类型化用例;编排观察准备、一次 Host 请求、结果/错误返回 | 第二套 attachment/Turn 索引、预算/审批政策 | +| `session-domain-observation.ts` | 每 attachment 的 Goal/Plan 通知、刷新合并、生命周期栅栏和有界投递 | 调度、持久化、Plan 状态机、完整历史缓存 | +| `session-registry.ts`(现有) | ownership、共享连接/attachment、关闭代次、用例装配 | 新增 Goal/Plan 业务算法和复制的刷新状态集合 | +| `turn-observation.ts`(现有) | 唯一输出消费者、admission/Stop/终态投递生命周期 | Goal/Plan 业务状态转换 | +| 现有 MCP、interactions、mapper、Session channel | 保留各自原责任 | 不为 PR8 分叉新实现 | + +依赖方向:`agent → routes → typed operations → 窄 Session ports → 现有 Registry/channel/Host`;Domain observer 通过注入的 query/notify/lifetime 接口工作,不反向引用 Registry 类。 + +窄接口只暴露本 PR 用到的能力:检查 owned/open、取得指定 Session 的 Host 请求能力、准备 retained observation、为确定 turnId 建立 admitted observation、检查当前 attachment 身份/关闭代次。不要把整个 Registry、private Map、MCP manager 或 channel 内部队列传进新模块。 + +允许从现有路径提取两项真实复用逻辑: + +- Host 错误映射:将现有 mapper 连同原调用点一起迁移到小模块;保留旧错误 data 语义,避免新增模块复制一份。 +- 非 prompt admission 准备:在 PR6 `_maka/turn/resume` 与 PR8 `plan.turn.start` 两个实际消费者之间共用“安装 presentation/interaction、等待 MCP ready、attachment、预注册 observer、保留 unknown outcome”的机械步骤。恢复安全查询和 Plan 审批各自留在用例内,不抽象整个命令事务。 + +Goal arm/resume 只需 retained observation,不预造 turnId,也不创建虚假 admitted Turn。Domain observer 每个 attachment 最多一个,销毁和替换由 Registry 的现有生命周期统一触发。 + +## 4. 对外协议 + +### 4.1 请求与响应 + +| ACP 请求 | Host 操作 | 输入及成功响应 | +| --- | --- | --- | +| `_maka/goal/query` | `goal.query` | 原样 `GoalQueryInput → GoalQueryResult` | +| `_maka/goal/arm` | `goal.arm` | 原样 `GoalArmInput → GoalArmResult`;maxIterations/tokenBudget 的 null 保持 Host 含义 | +| `_maka/goal/control` | `goal.control` | 原样 `GoalControlInput → GoalControlResult`,action 为 pause/resume/clear | +| `_maka/plan/query` | `plan.query` | 原样 `PlanQueryInput → PlanQueryResult`,包括 revision_changed | +| `_maka/plan/control` | `plan.control` | 原样 `PlanControlInput → PlanControlResult`,保留 operationId | +| `_maka/plan/turn/start` | `plan.turn.start` | 原样 `PlanTurnStartInput → PlanTurnStartResult`,保留 plan outcome 和 TurnSnapshot | + +复用 `HOST_OPERATION_SPECS[operation].decodeInput`,不重写 validation/constants。六个请求均要求本连接已通过 new/load/resume/copy 获得 Session ownership;知道 Session ID 不等于有控制权。Host 仍做自己的授权和业务校验。 + +Goal/Plan 成功响应表示 Host 已接受该操作,不表示任务成功完成。Plan start 返回 `{plan, turn}` 后,输出和交互继续走标准 ACP 通道,完成走已有 `_maka/turn/status`。 + +### 4.2 能力与状态通知 + +按 [ACP v1 扩展规范](https://agentclientprotocol.com/protocol/v1/extensibility),自定义方法使用 `_maka/` 前缀,能力写进 `_meta`,不向标准对象根部添加自定义字段。 + +建议新增: + +- Agent `agentCapabilities._meta["_maka/goalPlan"] = { version: 1 }`:表示本文六个请求和两种域通知契约可用。 +- Client `clientCapabilities._meta["_maka/goalPlanStatus"] = true`:选择接收域通知;原 `_maka/turnStatus: true` 继续单独控制非 prompt Turn 终态。 +- `_maka/goal/status`:`{sessionId, goal: GoalProjection | null}`,携带完整 Host 投影,包括 revision、预算和消耗。 +- `_maka/plan/changed`:`{sessionId, storeVersion, latestProposalId, activeExecutionId}`,字段取自成功 `plan.query(list_start)` 的权威 page;这是刷新提示,不能当成完整 Plan 或执行终态。客户端用 query 的分页读取 proposal/execution/steps。 + +请求能力与通知偏好分开:未订阅通知的客户端仍可调用六个请求并主动 query;普通 ACP 客户端继续收标准文本/工具/交互。方法名称、版本及精确 payload 要在本 PR 的 README 和 SDK 路由测试中固定。 + +六个路由的观察策略:query 默认只读;若客户端选择域通知,则首次 query 同时通过原 attachment 安装 retained observation 并提供初始快照,后续 query 复用。所有 mutation 在派发前准备 retained observation;load/resume 在选择通知后恢复域观察。这样新建但尚未 prompt 的 Session 也能被观察,不新增 subscribe 方法。 + +域通知是最新权威状态,不是每次转换的审计日志。允许合并中间刷新;同一 attachment 内不得被迟到旧结果回退。Goal 以 goalId/revision 去重,Plan 以 storeVersion 去重;重连或 canonical replacement 必须失效旧请求并重新读取,不跨 Host epoch 盲比版本数。 + +不保证响应先于通知:Host 可在请求尚未返回时产生输出或域变化,客户端必须先注册 handler,再按 Session、实体 ID 和版本关联。只有 Turn 终态通知需要严格等待该 Turn 的文本/工具输出 flush;域通知不充当输出完成屏障。 + +### 4.3 错误与未知结果 + +沿用 PR6 的 JSON-RPC 错误外壳和 `error.data.source/operation/code`;输入 decode 失败为 invalid params,保留 Host 的 operation_conflict、session_busy、session_archived、persistence_failed 等 code,不折叠成通用“失败”。 + +| 情形 | 对外行为与后续动作 | +| --- | --- | +| 本地取消/关闭发生在 dispatch 前 | 不发送 mutation;撤销本次未共享观察准备 | +| Host 明确拒绝 | 原错误;不更新 revision、换 ID 或重试;撤销本次独占准备,不破坏原 attachment | +| 命令已 dispatch 后连接丢失 | 返回带原 operation、sessionId、可用实体/operation/turn identity 和 `dispatch: dispatched` 的 outcome_unknown;不重发 | +| Plan start 丢响应 | 可复用 PR6 有界 `turn.query` 核实精确 turnId;只有完整且可关联的 plan 与 turn 结果都可建立才返回正常成功,否则保留 outcome_unknown,不伪造 PlanControlResult | +| Goal arm 丢响应 | query 可展示当前事实,但相同 condition/budget 不证明原请求成功;保留不确定性,不能据此自动重新 arm | +| Plan control persistence_failed | 保留 Host code 及未知结果含义;query 恢复可见状态,不自动变更 | +| 状态刷新失败 | 不撤销已确认 mutation,不虚报业务终态;保留 dirty,限定重试,日志写 stderr;显式 query 仍能返回可诊断错误 | + +未确认是否生效的 mutation 必须保留已准备的合法观察/交互资源,直到权威事实、close 或 dispose 决定其生命周期。请求 signal 的结束不等于 retained attachment 的结束。 + +## 5. 观察、顺序和资源生命周期 + +### 5.1 启动路径 + +1. Decode → owned/open 检查 → 获取共享连接,并在异步阶段后验证 Session close generation。 +2. 安装或替换非 prompt presentation/interaction client,等待该 Session 的 MCP 当前发布 ready;不重配或清空已有 MCP。 +3. 复用 `#ensureAttachment`,安装域 callback;从初始 snapshot/query 建立基线,同时处理其间已经到来的变化。 +4. Plan start 用调用方 turnId 在原 observation 索引预注册消费者,然后只发一次 `plan.turn.start`。`onTurnStarted` 发现同一个 Turn 时必须复用它。Goal resume 则提前保留 context,由 callback 接管 Host 生成的 Turn。 +5. 返回准入结果后保留 attachment。后续 Turn 通过原 started-turn barrier 顺序接管,文本/工具映射、pending interactions、transcript reconciliation 与 PR6 共用。 + +扩展启动的取消采用 PR6 admitted observation 的精确 identity 控制,不能只按 Session ID 停止一个可能已经接替的新 Turn。普通 prompt 已有消费者时,Goal/Plan 观察不能再启动第二个 `eventsForTurn` reader。 + +### 5.2 域状态刷新 + +Goal 从 `attachment.snapshot.goal` 初始化并消费 `onGoalChanged`;必要的恢复确认使用 goal.query。Plan 消费已有 `onSessionDomainChanged(domain === 'plan')`,触发 `plan.query(list_start)`,只发送有界 changed 提示,不自动扫描全部页。 + +Domain observer 内部仅持有:attachment/epoch identity、disposed 标记、每域 dirty、一个 in-flight refresh、最后已投递版本、最新待投递快照。刷新中再次收到变化则标 dirty,完成后补读一次;不能丢最后一次 invalidation,也不能为每帧建立无限 Promise 链。 + +重连时不仅依赖“Goal 值变了”:Plan 内容不在 Session snapshot 内,即使 root/Goal 未变也需要重新 query。复核 channel 的 `onRecovered` 触发条件(当前仅在 replacedLiveState 时调用)和 canonical `onSnapshotChanged` 路径;如现有回调不能可靠区分重建,新增一个明确、可选的 canonical-replacement callback,并由 ACP 实际消费。不得悄悄改变 TUI 的旧回调含义或新开订阅。 + +慢客户端采用一个在途通知加一个最新待发快照合并域状态;Turn 输出继续使用现有有界 mapper/channel。对 initial refresh 与后续刷新失败分别留测试,持续变更时不能用无界循环阻塞请求返回。 + +### 5.3 控制语义 + +| 操作 | 效果 | 不可隐式附加的动作 | +| --- | --- | --- | +| goal pause | 请求 Host 暂停 Goal 驱动,采用 Host 的现有在途处理 | 不承诺当前 Turn 已停、不改 Plan | +| goal resume | 请求 Host 恢复对应 Goal generation | 不创建替代 Goal、不发送普通 prompt | +| goal clear | 请求 Host 清除 Goal,保留返回的 cleared 投影 | 不删除 Session,不把 cleared 结果强行改成 null | +| session/cancel | 按现有逻辑停止所观察的精确当前 Turn | 不保证 Goal 永久暂停,不自动取消 Plan | +| plan.control cancel_execution | 请求取消计划状态;活跃 root 下允许 Host 返回 session_busy | 不偷偷 stop 后重试 | +| session/close、EOF | 保留 PR6 的当前 Turn stop 策略,再释放本连接 attachment、交互等待、MCP 和刷新资源 | 不额外 pause/clear Goal,不删除计划或 Session,不承诺 Host 的独立 Goal 调度永久停止 | + +若客户端希望“停止当前工作并禁止 Goal 后续续跑”,应显式先 pause Goal,再 cancel 当前 Turn。若希望取消活跃 Plan,先 cancel 并等待权威终态,再 query 后显式 cancel_execution。Adapter 不将这些多步意图假装成原子操作。 + +Domain observer 跟随 attachment 的成功安装、失败撤回、detach、close、dispose、revision abandon 清理;旧 query/notify 完成后先校验身份,不能复活资源或向新 attachment 写状态。新的 query/wait 必须可被关闭中断,不能让 EOF 等一个失联 Host 无限挂起。 + +## 6. 重复、冲突和恢复契约 + +- Goal control 使用客户端显式提交的 goalId/revision。冲突返回后由客户端 query,再作新的决定;禁止借用配置 CAS 的透明重试。 +- Plan control 的相同 operationId/输入由 Host receipt 判定;相同 ID、不同输入应保留冲突。Adapter 不建立持久化去重 store。 +- Plan start 的重复 turnId 由 Host 的 admission/digest 规则判定;本地只复用一个 observer,绝不能导致第二次真实执行。并发重复请求不能因其中一个失败而 dispose 另一个已经接管的 observer。 +- `revision_changed` 原样返回;客户端丢弃旧分页结果,从 list_start 重新阅读并重新审批。不能混合不同 storeVersion 的页。 +- 同一连接重连只重建观察并查询事实,不重播 arm/control/start;新 ACP 进程需 load/resume 获得 ownership,域通知偏好重新协商。 +- 不声称跨连接通知 exactly-once。重新 load 可重复展示历史;它与重复执行是两个不同问题。 +- 不由“当前 Goal + 当前 Turn”推断因果归属。只发送 Host 确认的 identity;Goal/Plan 快照和 Turn 状态按 Session 关联展示,不虚构某个 Turn 的 goalId/executionId。 + +## 7. 实现任务与完成条件 + +以下是一个 PR 内的可审查提交切片;每片带自己的测试和生产消费者。若拆成多个 PR,必须遵循 tracker“仅依赖已合并前置”的规则,不能依赖未合并堆叠证明功能。 + +| 顺序 | 工作 | 完成证据 | +| --- | --- | --- | +| T0 基线 | 在独立 worktree 核对最新官方 main、PR6 已合入、PR7 是否仍未合并;读适用规则;重建并跑 ACP/Host 定向基线 | 记录 SHA、命令、已有失败;核对 SDK 仍按锁文件,不顺手升级 | +| T1 Goal 纵向切片 | 六路由的基础只先落实际 Goal 三路由;复用 decoder/错误映射,准备 retained observation,Goal status 初始值与变化 | SDK new→arm/query→prompt→后台续跑→pause/resume/clear;arm 本身不发起模型调用 | +| T2 Plan 读取与控制 | Plan query/control、分页/冲突、Plan changed、域观察器的最小刷新合并 | SDK 读取真实 proposal,多页与 revision_changed;五种 control 输入均到 Host;外部修改后通知并 query 得到新事实 | +| T3 Plan 启动 | 提取并迁移 PR6/PR8 共用的非 prompt admission 机械步骤,接入 plan.turn.start | approve/start 和 interrupted resume 都可得到 admission、标准输出/交互、终态;只发一次 Host 命令,无额外 prompt | +| T4 时序加固 | 并发重复、未知结果、presentation 替换、取消/close/EOF、canonical refresh | deferred/barrier 测试证明单订阅、单消费者、迟到结果不污染、未知结果保留可观察性、资源可释放 | +| T5 交付 | 更新 ACP README/VALIDATION、能力矩阵和 SDK 用例;完成相关回归 | 每项功能有真实路由证据,测试对应最终 SHA;清楚列出未运行平台/客户端检查 | + +推荐依赖 `T0 → T1 → T2 → T3 → T4 → T5`。不要先创建尚无生产调用者的通用路由表或 admission 框架。文档和定向验证随每片更新,T5 只做最终集成核验。 + +## 8. 必须覆盖的验证矩阵 + +| 场景 | 验证断言 | +| --- | --- | +| Goal 首次驱动 | arm 保存正确预算/次数,无模型调用;显式首次 prompt 后,至少一轮 Host continuation 的文本、工具、交互无需额外 prompt 即到达 | +| Goal 控制 | pause/resume/clear 使用精确 generation;预算/终态来自 Host;过期 revision 不重试;resume 的后台 Turn 可观察 | +| Plan 真实生成 | 用可控模型服务经普通 plan-mode prompt 生成 proposal,再经 ACP query 读取;关键闭环不靠直接写数据库造计划 | +| Plan 执行 | query→plan.turn.start(approve)→真实输出/工具/交互→terminal;模型通过真实 update_plan 推进 steps,可 query 核验 | +| Plan 恢复与取消 | 中断执行后的 resume_execution 成功;活跃 control 返回忙;显式 stop/等待/取消状态链路正确;不可恢复状态保留 Host 拒绝 | +| 分页与审批 | 多页保持 storeVersion,翻页变化返回 revision_changed,旧审批 revision/storeVersion 冲突,不自动刷新并批准 | +| 重复请求 | 相同/不同 operationId 与输入组合、相同 turnId 并发/重连重发;实测模型运行次数不增加,观察者不互相释放 | +| 命令结果丢失 | mutation 已提交但响应丢失;adapter 零自动重发,精确身份保留;Goal 相同内容不被误判为同一次 arm | +| 外部控制 | 第二 Host 客户端改 Goal/Plan,原 ACP attachment 收更新并 query 得到权威状态,无第二订阅 | +| 输出顺序 | Turn 输出阻塞时 successor 已开始/结束,仍保持原 barrier;终态晚于其标准输出;没有无关 prompt response | +| 权限与交互 | pending permission/form 恢复、外部回答、客户端缺能力、通知失败;Host 保持答案权威;不能扩大 PR6 的停止策略 | +| 重连/恢复 | root/Goal 不变但 Plan 已改变,仍能刷新;旧 query 晚返回不得回退;新进程 load 后不重启执行 | +| 资源退出 | query/attach/dispatch/notify 各阶段取消、close、EOF、abandon;无残留监听、挂起刷新或 MCP 进程;等待共享消费者不互相撤销 | +| 标准兼容 | 不声明扩展通知能力时,普通 initialize/new/prompt/cancel/load/resume/close 与 PR6 行为一致 | + +测试位置建议:`acp-agent.test.ts` 增加路由/能力/decoder;新增 `acp-goal-plan-operations.test.ts` 与 `acp-session-domain-observation.test.ts`;现有 registry/restore 测试保留回归断言;新增 `acp-goal-plan-child-process.test.ts`,复用现有官方 SDK 子进程 harness、可控模型服务及 MCP fixture。 + +内部假 Host 用于制造确定竞态,真实 ACP/Host 进程用于证明执行效果。时间同步用 deferred/事件屏障,timeout 只防挂,禁止用固定 sleep 当正确性断言。不需要外部模型 API key 来完成可控模型服务的测试。 + +## 9. 验证命令与发布门槛 + +以实施时 package.json/CI 为准;当前 CLI workspace 名是 `maka-agent`,不是 `@maka/cli`。 + +```sh +npm --workspace maka-agent run build:workspace-deps +npm --workspace maka-agent run build +node --test packages/cli/dist/__tests__/acp-*.test.js +npm --workspace maka-agent run test:dist +npm --workspace @maka/runtime-host run test:dist +npm run typecheck +npm run lint +npm run format:check +npm run check:cli-third-party-notices +npm run check:asf-headers +git diff --check +``` + +依赖构建若需要额外 workspace,按仓库真实依赖补齐;不能把旧 dist 通过算作新实现验证。共享 channel 改动必须补 TUI session driver 与 subscription/projector 回归;公共 Host seam 改动必须补 Host Goal/Plan、admission、continuity 回归。最终执行仓库要求的 build/test/协议兼容检查,平台 CI 覆盖按实际可用性记录。 + +不预期修改 Host wire schema、epoch、SDK 或依赖。如果发现做不到,先记录具体缺失契约及影响,再作独立小范围设计,不用更改 Host 状态机绕过 adapter 难题。Zed smoke 用于验证标准流程;它不替代私有扩展的官方 SDK 测试。 + +Done 同时要求:六个请求可用、Goal/Plan 真实执行可观察、命令不自动重发、控制/关闭语义明确、单订阅/单 Turn consumer、失败清理与跨客户端刷新有证据、文档匹配实际能力。PR7 未合并时不能宣称 #3132 整体完成。 + +## 10. 基线变化与交接约束 + +核对时官方 main 比本地多 4 个提交,其中 `8d5a3cac3`(#5721)删除 `form_interaction`、`transcript_changed` 及相应 CLI/Host 恢复分支,Host epoch 为 189。实现必须基于最新已合并代码复核恢复测试,不复制本地旧分支的这些已移除行为。其余提交主要为 UI,但依赖锁文件也有变化。 + +新任务应首先读取本文,将它复制进自己的独立 worktree 作为设计基线,再完成 T0。新任务可以实现、测试和整理本地提交;本轮交接不要求发布评论、合并、部署或自动修改 issue tracker。需要调整设计时,在本文记录已核实的原因与新契约,保持用户要求的清晰边界、稳定结构、低耦合和实际复用。 + +当前工作区已有另一份未跟踪设计文件,应原样保留。不要把本任务之外的工作区文件或改动带进实现提交。 + +## 11. 实施核对(2026-09-26) + +独立 worktree 采用官方 main `87fc9f69cd11648f31048c1b633bb813aca5f515`, +已包含 PR6;核对时 PR7 #5685 仍为 open。SDK 维持锁文件中的 1.4.0, +未修改 Host wire schema、epoch 或依赖。#5721 删除的旧恢复路径未被重新引入。 + +六个请求、两类域通知与真实 SDK/stdio/Host/可控模型闭环已落地,实际协议 +和本设计第 4 节一致。实现中唯一新增的 channel 契约是可选的 +`onCanonicalReplacement(snapshot)` 回调:它只在真正替换权威订阅时触发, +用于即使 Goal/root 不变也刷新 Plan;原 `onRecovered` 和 TUI 语义不变。 +丢失 Plan 启动响应时保留 `outcome_unknown` 与观察,并对精确 `turnId` 启动 +已有的有界 admission query;不会从仅有的 Turn 事实伪造 Plan 结果。 + +验证记录见 `packages/cli/src/acp/VALIDATION.md`。尚未运行的客户端/平台检查 +也列于该记录。 diff --git a/packages/cli/src/__tests__/acp-agent.test.ts b/packages/cli/src/__tests__/acp-agent.test.ts index 2cee82858c..bfd2cfc9f1 100644 --- a/packages/cli/src/__tests__/acp-agent.test.ts +++ b/packages/cli/src/__tests__/acp-agent.test.ts @@ -32,6 +32,7 @@ describe('Maka ACP agent', () => { agentCapabilities: { loadSession: true, sessionCapabilities: { list: {}, resume: {}, close: {} }, + _meta: { '_maka/goalPlan': { version: 1 } }, }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, @@ -98,6 +99,137 @@ describe('Maka ACP agent', () => { ); }); + test('decodes all Goal/Plan extension inputs with Host specifications', async () => { + const controls: unknown[] = []; + const starts: unknown[] = []; + const registry = { + ...fakeSessionRegistry(), + goalQuery: async (input: unknown) => ({ + sessionId: (input as { sessionId: string }).sessionId, + goal: null, + }), + goalArm: async (input: unknown) => { + controls.push(input); + return { sessionId: 'session-1', goal: {} } as never; + }, + goalControl: async (input: unknown) => { + controls.push(input); + return { sessionId: 'session-1', goal: {} } as never; + }, + planQuery: async (input: unknown) => { + controls.push(input); + return { + kind: 'page' as const, + sessionId: 'session-1', + storeVersion: 0, + latestProposalId: null, + activeExecutionId: null, + items: [], + nextCursor: null, + }; + }, + planControl: async (input: unknown) => { + controls.push(input); + return { + sessionId: 'session-1', + storeVersion: 1, + eventType: 'plan_abandoned' as const, + proposalId: null, + executionId: null, + }; + }, + planTurnStart: async (input: unknown) => { + starts.push(input); + return { plan: {}, turn: {} } as never; + }, + }; + await client({ name: 'test-client' }).connectWith( + createMakaAcpAgent({ version: '0.2.0', sessionRegistry: registry }), + async (agent) => { + await agent.request(methods.agent.initialize, { protocolVersion: 1 }); + assert.deepEqual(await agent.request('_maka/goal/query', { sessionId: 'session-1' }), { + sessionId: 'session-1', + goal: null, + }); + await agent.request('_maka/goal/arm', { + sessionId: 'session-1', + condition: 'Finish', + maxIterations: null, + tokenBudget: null, + }); + await agent.request('_maka/goal/control', { + sessionId: 'session-1', + goalId: 'goal-1', + expectedRevision: 1, + action: 'pause', + }); + await agent.request('_maka/plan/query', { kind: 'list_start', sessionId: 'session-1' }); + for (const input of [ + { + kind: 'request_revision', + sessionId: 'session-1', + proposalId: 'proposal-1', + operationId: 'op-1', + }, + { + kind: 'abandon_proposal', + sessionId: 'session-1', + proposalId: 'proposal-1', + operationId: 'op-2', + }, + { + kind: 'approve_proposal', + sessionId: 'session-1', + proposalId: 'proposal-1', + expectedRevision: 1, + expectedStoreVersion: 1, + operationId: 'op-3', + }, + { + kind: 'resume_execution', + sessionId: 'session-1', + executionId: 'exec-1', + operationId: 'op-4', + }, + { + kind: 'cancel_execution', + sessionId: 'session-1', + executionId: 'exec-1', + operationId: 'op-5', + }, + ]) + await agent.request('_maka/plan/control', input); + await agent.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId: 'session-1', + proposalId: 'proposal-1', + expectedRevision: 1, + expectedStoreVersion: 1, + turnId: 'turn-1', + }); + await agent.request('_maka/plan/turn/start', { + kind: 'resume_execution', + sessionId: 'session-1', + executionId: 'execution-1', + turnId: 'turn-2', + }); + assert.equal(controls.length, 8); + assert.equal(starts.length, 2); + await assert.rejects( + agent.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId: 'session-1', + }), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + await assert.rejects( + agent.request('_maka/goal/arm', { sessionId: 'session-1', condition: 'Finish' }), + (error: unknown) => error instanceof RequestError && error.code === -32602, + ); + }, + ); + }); + test('routes bounded copy-source queries and rejects invalid input through the SDK', async () => { await client({ name: 'test-client' }).connectWith( createMakaAcpAgent({ version: '0.2.0', sessionRegistry: fakeSessionRegistry() }), @@ -300,6 +432,24 @@ function fakeSessionRegistry( reason: 'resume_candidate_missing' as const, }, }), + goalQuery: async () => { + throw new Error('unused Goal query'); + }, + goalArm: async () => { + throw new Error('unused Goal arm'); + }, + goalControl: async () => { + throw new Error('unused Goal control'); + }, + planQuery: async () => { + throw new Error('unused Plan query'); + }, + planControl: async () => { + throw new Error('unused Plan control'); + }, + planTurnStart: async () => { + throw new Error('unused Plan Turn start'); + }, queryCopySource: async () => ({ sessionId: 'session-1', expectedSourceRevision: 1, diff --git a/packages/cli/src/__tests__/acp-child-process.test.ts b/packages/cli/src/__tests__/acp-child-process.test.ts index 7016d31ecc..98166cd93b 100644 --- a/packages/cli/src/__tests__/acp-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-child-process.test.ts @@ -145,6 +145,7 @@ describe('Maka ACP child process', () => { agentCapabilities: { loadSession: true, sessionCapabilities: { list: {}, resume: {}, close: {} }, + _meta: { '_maka/goalPlan': { version: 1 } }, }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, diff --git a/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts b/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts new file mode 100644 index 0000000000..4f73b5e1cb --- /dev/null +++ b/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts @@ -0,0 +1,1097 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { createServer, type ServerResponse, type IncomingMessage } from 'node:http'; +import { describe, test } from 'node:test'; +import { methods, RequestError } from '@agentclientprotocol/sdk'; +import { waitFor } from '@maka/core/test-only/async-primitives'; +import { connectRuntimeHost } from '@maka/runtime-host/client'; +import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import { withAcpChildProcessHarness } from './acp-child-process-harness.js'; + +describe('ACP Goal/Plan real Host routes', () => { + test('cancel stops the exact Plan Turn and close/EOF release its attachment', { + timeout: 30_000, + }, async () => { + let submitted = false; + let executionStarted!: () => void; + const started = new Promise((resolve) => { + executionStarted = resolve; + }); + const model = createServer((request, response) => { + void readRequest(request) + .then((body) => { + const input = JSON.parse(body) as { + stream?: boolean; + tools?: Array<{ function?: { name?: string } }>; + }; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'summary', + object: 'chat.completion', + created: 1, + model: 'cancel-plan-fixture', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Summary' }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + const names = (input.tools ?? []).flatMap((tool) => + tool.function?.name ? [tool.function.name] : [], + ); + if (!submitted && names.includes('SubmitPlan')) { + submitted = true; + respondTool(response, 'SubmitPlan', { + title: 'Cancellable plan', + steps: [{ id: 'step-1', title: 'Wait', description: 'Wait for cancellation' }], + }); + } else if (!submitted) respondTool(response, 'tool_search', { query: 'SubmitPlan' }); + else { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write( + `data: ${JSON.stringify(modelChunk({ role: 'assistant', content: 'Working.' }, null))}\n\n`, + ); + executionStarted(); + } + }) + .catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + model.once('error', reject); + model.listen(0, '127.0.0.1', resolve); + }); + const address = model.address(); + assert.ok(address && typeof address !== 'string'); + try { + await withAcpChildProcessHarness( + async (harness) => { + const statuses: unknown[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { _meta: { '_maka/turnStatus': true } }, + }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Prepare a cancellable plan' }], + }); + const page = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + storeVersion: number; + items: Array<{ kind: string; proposal?: { proposalId: string; revision: number } }>; + }; + const proposal = page.items.find((item) => item.kind === 'proposal')?.proposal; + assert.ok(proposal); + await context.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId, + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + expectedStoreVersion: page.storeVersion, + turnId: randomUUID(), + }); + await started; + await context.notify(methods.agent.session.cancel, { sessionId }); + await waitFor(() => statuses.length > 0); + const current = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + items: Array<{ kind: string; execution?: { status: string; executionId: string } }>; + }; + const execution = current.items.find((item) => item.kind === 'execution')?.execution; + assert.equal(execution?.status, 'interrupted'); + await context.request('_maka/plan/control', { + kind: 'cancel_execution', + sessionId, + executionId: execution.executionId, + operationId: randomUUID(), + }); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app.onNotification( + '_maka/turn/status', + { parse: (value: unknown) => value }, + ({ params }) => { + statuses.push(params); + }, + ), + ); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + }, + { + startRuntimeHost: true, + model: { + id: 'cancel-plan-fixture', + thinkingLevels: [], + baseUrl: `http://127.0.0.1:${address.port}/v1`, + }, + }, + ); + } finally { + await new Promise((resolve) => model.close(() => resolve())); + } + }); + + test('Plan paging preserves storeVersion and returns revision_changed after an external write', { + timeout: 45_000, + }, async () => { + let call = 0; + const model = createServer((request, response) => { + void readRequest(request) + .then((body) => { + const input = JSON.parse(body) as { + stream?: boolean; + tools?: Array<{ function?: { name?: string } }>; + }; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'summary', + object: 'chat.completion', + created: 1, + model: 'paging-fixture', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Summary' }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + call += 1; + const names = (input.tools ?? []).flatMap((tool) => + tool.function?.name ? [tool.function.name] : [], + ); + if (names.includes('SubmitPlan')) + respondTool(response, 'SubmitPlan', { + title: `Fixture plan ${call}`, + steps: [ + { id: 'step-1', title: 'Do the work', description: 'Finish the fixture task' }, + ], + }); + else respondTool(response, 'tool_search', { query: 'SubmitPlan' }); + }) + .catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + model.once('error', reject); + model.listen(0, '127.0.0.1', resolve); + }); + const address = model.address(); + assert.ok(address && typeof address !== 'string'); + try { + await withAcpChildProcessHarness( + async (harness) => { + await harness.withClient(async ({ context }) => { + await context.request(methods.agent.initialize, { protocolVersion: 1 }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + let latestProposalId = ''; + for (let index = 0; index < 17; index += 1) { + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: `Prepare fixture plan ${index}` }], + }); + const latest = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + kind: string; + latestProposalId: string; + }; + assert.equal(latest.kind, 'page'); + latestProposalId = latest.latestProposalId; + if (index < 16) { + try { + await context.request('_maka/plan/control', { + kind: 'request_revision', + sessionId, + proposalId: latestProposalId, + operationId: randomUUID(), + }); + } catch (error) { + throw new Error( + `revision index=${index} proposal=${latestProposalId} modelCalls=${call}`, + { cause: error }, + ); + } + } + } + const first = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + kind: string; + storeVersion: number; + nextCursor: string | null; + items: unknown[]; + }; + assert.equal(first.kind, 'page'); + assert.equal(first.items.length, 16); + assert.ok(first.nextCursor); + const second = (await context.request('_maka/plan/query', { + kind: 'list_continue', + sessionId, + storeVersion: first.storeVersion, + cursor: first.nextCursor, + })) as typeof first; + assert.equal(second.kind, 'page'); + assert.ok(second.items.length > 0); + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + if (connected.kind !== 'connected') assert.fail('Second Host client unavailable'); + try { + await connected.connection.request('plan.control', { + kind: 'request_revision', + sessionId, + proposalId: latestProposalId, + operationId: randomUUID(), + }); + } finally { + await connected.connection.close(); + } + const changed = (await context.request('_maka/plan/query', { + kind: 'list_continue', + sessionId, + storeVersion: first.storeVersion, + cursor: first.nextCursor, + })) as { kind: string; expected: number; actual: number }; + assert.equal(changed.kind, 'revision_changed'); + assert.equal(changed.expected, first.storeVersion); + assert.ok(changed.actual > first.storeVersion); + await context.request(methods.agent.session.close, { sessionId }); + }); + }, + { + startRuntimeHost: true, + model: { + id: 'paging-fixture', + thinkingLevels: [], + baseUrl: `http://127.0.0.1:${address.port}/v1`, + }, + }, + ); + } finally { + await new Promise((resolve) => model.close(() => resolve())); + } + }); + + test('Goal arm is passive and a prompt enables observable Host continuation', { + timeout: 30_000, + }, async () => { + let streamCalls = 0; + const model = createServer((request, response) => { + void readRequest(request) + .then((body) => { + const input = JSON.parse(body) as { stream?: boolean }; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'goal-evaluation', + object: 'chat.completion', + created: 1, + model: 'goal-fixture', + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: JSON.stringify({ + met: false, + impossible: false, + progress: true, + waiting: false, + reason: 'Continue', + }), + }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + streamCalls += 1; + respondEvents(response, [ + modelChunk({ role: 'assistant', content: `Goal turn ${streamCalls}.` }, null), + modelChunk({}, 'stop'), + ]); + }) + .catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + model.once('error', reject); + model.listen(0, '127.0.0.1', resolve); + }); + const address = model.address(); + assert.ok(address && typeof address !== 'string'); + try { + await withAcpChildProcessHarness( + async (harness) => { + const chunks: string[] = []; + const statuses: unknown[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { + _meta: { '_maka/turnStatus': true, '_maka/goalPlanStatus': true }, + }, + }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request('_maka/goal/arm', { + sessionId, + condition: 'Complete both fixture turns', + maxIterations: 2, + tokenBudget: null, + }); + assert.equal(streamCalls, 0, 'arming alone did not dispatch a Turn'); + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Start the fixture task' }], + }); + await waitFor(() => streamCalls >= 2 && statuses.length > 0); + assert.ok( + chunks.some((chunk) => chunk.includes('Goal turn 2.')), + `Missing background output: ${JSON.stringify(chunks)}`, + ); + await context.request(methods.agent.session.close, { sessionId }); + + const second = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + const armed = (await context.request('_maka/goal/arm', { + sessionId: second.sessionId, + condition: 'Do one fixture turn', + maxIterations: 1, + tokenBudget: null, + })) as { goal: { goalId: string; revision: number } }; + const paused = (await context.request('_maka/goal/control', { + sessionId: second.sessionId, + goalId: armed.goal.goalId, + expectedRevision: armed.goal.revision, + action: 'pause', + })) as typeof armed; + const callsBeforeResume = streamCalls; + const statusesBeforeResume = statuses.length; + await context.request('_maka/goal/control', { + sessionId: second.sessionId, + goalId: armed.goal.goalId, + expectedRevision: paused.goal.revision, + action: 'resume', + }); + await waitFor( + () => streamCalls > callsBeforeResume && statuses.length > statusesBeforeResume, + ); + assert.ok(chunks.some((chunk) => chunk.includes(`Goal turn ${streamCalls}.`))); + await context.request(methods.agent.session.close, { sessionId: second.sessionId }); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' + ) + chunks.push(params.update.content.text); + }) + .onNotification( + '_maka/turn/status', + { parse: (value: unknown) => value }, + ({ params }) => { + statuses.push(params); + }, + ), + ); + }, + { + startRuntimeHost: true, + model: { + id: 'goal-fixture', + thinkingLevels: [], + baseUrl: `http://127.0.0.1:${address.port}/v1`, + }, + }, + ); + } finally { + await new Promise((resolve) => model.close(() => resolve())); + } + }); + + test('submits and executes a Plan through the model, ACP, and real Host', { + timeout: 30_000, + }, async () => { + let modelCalls = 0; + const modelToolNames: string[][] = []; + let submitted = false; + let questionAsked = false; + let updated = false; + let interruptNextExecution = false; + const model = createServer((request, response) => { + void readRequest(request) + .then((body) => { + const input = JSON.parse(body) as { + stream?: boolean; + tools?: Array<{ function?: { name?: string } }>; + }; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'summary', + object: 'chat.completion', + created: 1, + model: 'goal-plan-fixture', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Summary' }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + const names = (input.tools ?? []).flatMap((tool) => + tool.function?.name ? [tool.function.name] : [], + ); + modelToolNames.push(names); + modelCalls += 1; + if (!submitted && names.includes('SubmitPlan')) { + submitted = true; + respondTool(response, 'SubmitPlan', { + title: 'Fixture plan', + steps: [ + { id: 'step-1', title: 'Do the work', description: 'Finish the fixture task' }, + ], + }); + } else if (!submitted) { + respondTool(response, 'tool_search', { query: 'SubmitPlan' }); + } else if (interruptNextExecution) { + interruptNextExecution = false; + respondEvents(response, [ + modelChunk({ role: 'assistant', content: 'Work remains.' }, null), + modelChunk({}, 'stop'), + ]); + } else if (!questionAsked && names.includes('AskUserQuestion')) { + questionAsked = true; + respondTool(response, 'AskUserQuestion', { + questions: [ + { + question: 'Proceed with the fixture?', + options: [{ label: 'Yes' }, { label: 'No' }], + }, + ], + }); + } else if (!questionAsked) { + respondTool(response, 'tool_search', { query: 'AskUserQuestion' }); + } else if (!updated && names.includes('update_plan')) { + updated = true; + respondTool(response, 'update_plan', { + steps: [{ id: 'step-1', status: 'completed', note: 'Done' }], + }); + } else if (!updated) { + respondTool(response, 'tool_search', { query: 'update_plan' }); + } else { + respondEvents(response, [ + modelChunk({ role: 'assistant', content: 'Plan complete.' }, null), + modelChunk({}, 'stop'), + ]); + } + }) + .catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + model.once('error', reject); + model.listen(0, '127.0.0.1', resolve); + }); + const address = model.address(); + assert.ok(address && typeof address !== 'string'); + try { + await withAcpChildProcessHarness( + async (harness) => { + const updates: unknown[] = []; + const statuses: unknown[] = []; + const planChanges: unknown[] = []; + const deliveryOrder: string[] = []; + let interactions = 0; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { + elicitation: { form: {} }, + _meta: { '_maka/turnStatus': true, '_maka/goalPlanStatus': true }, + }, + }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + assert.deepEqual( + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Prepare a plan for a fixture task' }], + }), + { stopReason: 'end_turn' }, + ); + const page = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + kind: string; + storeVersion: number; + items: Array<{ + kind: string; + proposal?: { proposalId: string; revision: number; status: string }; + }>; + }; + assert.equal(page.kind, 'page'); + const proposal = page.items.find((item) => item.kind === 'proposal')?.proposal; + assert.ok( + proposal, + `modelCalls=${modelCalls} tools=${JSON.stringify(modelToolNames)} page=${JSON.stringify(page)} updates=${JSON.stringify(updates)}`, + ); + assert.equal(proposal.status, 'pending_approval'); + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { + min: RUNTIME_HOST_PROTOCOL_VERSION, + max: RUNTIME_HOST_PROTOCOL_VERSION, + }, + }); + if (connected.kind !== 'connected') assert.fail('Second Host client unavailable'); + let revisedStoreVersion: number; + try { + const revised = await connected.connection.request('plan.control', { + kind: 'request_revision', + sessionId, + proposalId: proposal.proposalId, + operationId: randomUUID(), + }); + revisedStoreVersion = revised.storeVersion; + } finally { + await connected.connection.close(); + } + await waitFor(() => + planChanges.some( + (status) => + (status as { storeVersion?: number }).storeVersion === revisedStoreVersion, + ), + ); + const externallyChanged = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as typeof page; + assert.equal(externallyChanged.storeVersion, revisedStoreVersion); + await assert.rejects( + context.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId, + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + expectedStoreVersion: page.storeVersion, + turnId: randomUUID(), + }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string } | undefined)?.code === 'operation_conflict', + ); + submitted = false; + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Revise the fixture plan' }], + }); + const approvalPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as typeof page; + const approvalProposal = approvalPage.items.find( + (item) => item.kind === 'proposal' && item.proposal?.status === 'pending_approval', + )?.proposal; + assert.ok(approvalProposal); + const turnId = randomUUID(); + const approvalInput = { + kind: 'approve_proposal', + sessionId, + proposalId: approvalProposal.proposalId, + expectedRevision: approvalProposal.revision, + expectedStoreVersion: approvalPage.storeVersion, + turnId, + }; + const concurrentResults = await Promise.allSettled([ + context.request('_maka/plan/turn/start', approvalInput), + context.request('_maka/plan/turn/start', approvalInput), + ]); + const successful = concurrentResults.find((result) => result.status === 'fulfilled'); + assert.ok(successful); + const started = successful.value as { + plan: { eventType: string; executionId: string }; + turn: { turnId: string }; + }; + for (const result of concurrentResults) { + if (result.status === 'rejected') { + assert.ok(result.reason instanceof RequestError); + assert.equal((result.reason.data as { code?: string }).code, 'session_busy'); + } + } + assert.equal(started.plan.eventType, 'plan_approved'); + assert.equal(started.turn.turnId, turnId); + await waitFor(() => statuses.length > 0); + const completedPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + kind: string; + items: Array<{ + kind: string; + execution?: { status: string; steps: Array<{ status: string }> }; + }>; + }; + const execution = completedPage.items.find( + (item) => item.kind === 'execution', + )?.execution; + assert.equal(execution?.status, 'completed'); + assert.deepEqual( + execution.steps.map((step) => step.status), + ['completed'], + ); + const callsBeforeRepeat = modelCalls; + const statusesBeforeRepeat = statuses.length; + const repeated = (await context.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId, + proposalId: approvalProposal.proposalId, + expectedRevision: approvalProposal.revision, + expectedStoreVersion: approvalPage.storeVersion, + turnId, + })) as typeof started; + assert.equal(repeated.turn.turnId, turnId); + assert.equal( + modelCalls, + callsBeforeRepeat, + 'same turnId did not run the model again', + ); + assert.equal( + statuses.length, + statusesBeforeRepeat, + 'receipt replay did not start a second output consumer', + ); + await context.request(methods.agent.session.close, { sessionId }); + assert.ok(updates.length > 0); + assert.ok(statuses.length > 0); + assert.ok(planChanges.length > 0); + assert.equal(interactions, 1); + assert.ok(deliveryOrder.indexOf('plan-output') >= 0); + assert.ok( + deliveryOrder.indexOf('plan-status') > deliveryOrder.indexOf('plan-output'), + ); + + submitted = false; + questionAsked = false; + updated = false; + interruptNextExecution = true; + const resumedSession = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId: resumedSession.sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + await context.request(methods.agent.session.prompt, { + sessionId: resumedSession.sessionId, + prompt: [{ type: 'text', text: 'Prepare an interrupted fixture plan' }], + }); + const interruptedProposalPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId: resumedSession.sessionId, + })) as typeof page; + const interruptedProposal = interruptedProposalPage.items.find( + (item) => item.kind === 'proposal', + )?.proposal; + assert.ok(interruptedProposal); + const statusesBeforeInterrupted = statuses.length; + const interruptedAdmission = (await context.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId: resumedSession.sessionId, + proposalId: interruptedProposal.proposalId, + expectedRevision: interruptedProposal.revision, + expectedStoreVersion: interruptedProposalPage.storeVersion, + turnId: randomUUID(), + })) as typeof started; + await waitFor(() => statuses.length > statusesBeforeInterrupted); + const interruptedPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId: resumedSession.sessionId, + })) as typeof completedPage; + assert.equal( + interruptedPage.items.find((item) => item.kind === 'execution')?.execution?.status, + 'interrupted', + ); + const statusesBeforeResume = statuses.length; + const resumedAdmission = (await context.request('_maka/plan/turn/start', { + kind: 'resume_execution', + sessionId: resumedSession.sessionId, + executionId: interruptedAdmission.plan.executionId, + turnId: randomUUID(), + })) as typeof started; + assert.equal(resumedAdmission.plan.eventType, 'plan_execution_resumed'); + await waitFor(() => statuses.length > statusesBeforeResume); + const resumedPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId: resumedSession.sessionId, + })) as typeof completedPage; + assert.equal( + resumedPage.items.find((item) => item.kind === 'execution')?.execution?.status, + 'completed', + ); + await context.request(methods.agent.session.close, { + sessionId: resumedSession.sessionId, + }); + + submitted = false; + questionAsked = false; + updated = false; + interruptNextExecution = true; + const cancelledSession = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId: cancelledSession.sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + await context.request(methods.agent.session.prompt, { + sessionId: cancelledSession.sessionId, + prompt: [{ type: 'text', text: 'Prepare a cancellable fixture plan' }], + }); + const cancelProposalPage = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId: cancelledSession.sessionId, + })) as typeof page; + const cancelProposal = cancelProposalPage.items.find( + (item) => item.kind === 'proposal', + )?.proposal; + assert.ok(cancelProposal); + const statusesBeforeCancelTurn = statuses.length; + const cancelAdmission = (await context.request('_maka/plan/turn/start', { + kind: 'approve_proposal', + sessionId: cancelledSession.sessionId, + proposalId: cancelProposal.proposalId, + expectedRevision: cancelProposal.revision, + expectedStoreVersion: cancelProposalPage.storeVersion, + turnId: randomUUID(), + })) as typeof started; + await waitFor(() => statuses.length > statusesBeforeCancelTurn); + const cancelled = (await context.request('_maka/plan/control', { + kind: 'cancel_execution', + sessionId: cancelledSession.sessionId, + executionId: cancelAdmission.plan.executionId, + operationId: randomUUID(), + })) as { eventType: string }; + assert.equal(cancelled.eventType, 'plan_execution_cancelled'); + const callsBeforeRejectedResume = modelCalls; + await assert.rejects( + context.request('_maka/plan/turn/start', { + kind: 'resume_execution', + sessionId: cancelledSession.sessionId, + executionId: cancelAdmission.plan.executionId, + turnId: randomUUID(), + }), + (error: unknown) => error instanceof RequestError, + ); + assert.equal(modelCalls, callsBeforeRejectedResume); + await context.request(methods.agent.session.close, { + sessionId: cancelledSession.sessionId, + }); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + updates.push(params); + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' && + params.update.content.text.includes('Plan complete.') + ) + deliveryOrder.push('plan-output'); + }) + .onNotification( + '_maka/turn/status', + { parse: (value: unknown) => value }, + ({ params }) => { + statuses.push(params); + deliveryOrder.push('plan-status'); + }, + ) + .onNotification( + '_maka/plan/changed', + { parse: (value: unknown) => value }, + ({ params }) => { + planChanges.push(params); + }, + ) + .onRequest(methods.client.elicitation.create, () => { + interactions += 1; + return { action: 'accept', content: { q0: 'Yes' } }; + }), + ); + }, + { + startRuntimeHost: true, + model: { + id: 'goal-plan-fixture', + thinkingLevels: [], + baseUrl: `http://127.0.0.1:${address.port}/v1`, + }, + }, + ); + } finally { + await new Promise((resolve) => model.close(() => resolve())); + } + }); + + test('owns, arms, queries and controls a Goal while observing domain state', { + timeout: 30_000, + }, async () => { + await withAcpChildProcessHarness( + async (harness) => { + const goalStatuses: unknown[] = []; + const planChanges: unknown[] = []; + await harness.withClient( + async ({ context }) => { + const initialized = await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { _meta: { '_maka/goalPlanStatus': true } }, + }); + assert.deepEqual(initialized.agentCapabilities?._meta?.['_maka/goalPlan'], { + version: 1, + }); + await assert.rejects( + context.request('_maka/goal/query', { sessionId: 'unowned-session' }), + (error: unknown) => + error instanceof RequestError && + (error.data as { reason?: string } | undefined)?.reason === 'unknown_session', + ); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + assert.deepEqual(await context.request('_maka/goal/query', { sessionId }), { + sessionId, + goal: null, + }); + const initialPlan = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + kind: string; + storeVersion: number; + items: unknown[]; + }; + assert.equal(initialPlan.kind, 'page'); + assert.deepEqual(initialPlan.items, []); + const armed = (await context.request('_maka/goal/arm', { + sessionId, + condition: 'Write a short summary', + maxIterations: 2, + tokenBudget: null, + })) as { + goal: { + goalId: string; + revision: number; + maxIterations: number; + tokenBudget: number | null; + }; + }; + assert.equal(armed.goal.maxIterations, 2); + assert.equal(armed.goal.tokenBudget, null); + const queried = (await context.request('_maka/goal/query', { + sessionId, + })) as typeof armed; + assert.equal(queried.goal.goalId, armed.goal.goalId); + await assert.rejects( + context.request('_maka/goal/control', { + sessionId, + goalId: armed.goal.goalId, + expectedRevision: armed.goal.revision + 1, + action: 'pause', + }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string } | undefined)?.code === 'operation_conflict', + ); + const connected = await connectRuntimeHost({ + rootPath: harness.workspaceRoot, + protocol: { min: RUNTIME_HOST_PROTOCOL_VERSION, max: RUNTIME_HOST_PROTOCOL_VERSION }, + }); + if (connected.kind !== 'connected') assert.fail('Second Host client unavailable'); + let paused: typeof armed; + try { + paused = await connected.connection.request('goal.control', { + sessionId, + goalId: armed.goal.goalId, + expectedRevision: armed.goal.revision, + action: 'pause', + }); + } finally { + await connected.connection.close(); + } + assert.equal(paused.goal.goalId, armed.goal.goalId); + assert.ok(paused.goal.revision > armed.goal.revision); + await waitFor(() => + goalStatuses.some( + (status) => + (status as { goal?: { revision?: number } }).goal?.revision === + paused.goal.revision, + ), + ); + const externalQuery = (await context.request('_maka/goal/query', { + sessionId, + })) as typeof armed; + assert.equal(externalQuery.goal.revision, paused.goal.revision); + await context.request('_maka/goal/control', { + sessionId, + goalId: armed.goal.goalId, + expectedRevision: paused.goal.revision, + action: 'clear', + }); + assert.ok(goalStatuses.length > 0); + assert.ok(planChanges.length > 0); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app + .onNotification( + '_maka/goal/status', + { parse: (value: unknown) => value }, + ({ params }) => { + goalStatuses.push(params); + }, + ) + .onNotification( + '_maka/plan/changed', + { parse: (value: unknown) => value }, + ({ params }) => { + planChanges.push(params); + }, + ), + ); + }, + { startRuntimeHost: true, model: { id: 'goal-plan-fixture', thinkingLevels: [] } }, + ); + }); +}); + +function modelChunk(delta: Record, finishReason: 'tool_calls' | 'stop' | null) { + return { + id: 'chatcmpl-goal-plan-fixture', + object: 'chat.completion.chunk', + created: 1, + model: 'goal-plan-fixture', + choices: [{ index: 0, delta, finish_reason: finishReason }], + ...(finishReason + ? { usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } } + : {}), + }; +} + +function respondEvents(response: ServerResponse, events: readonly unknown[]): void { + response.writeHead(200, { 'content-type': 'text/event-stream' }); + for (const event of events) response.write(`data: ${JSON.stringify(event)}\n\n`); + response.end('data: [DONE]\n\n'); +} + +function respondTool(response: ServerResponse, name: string, args: Record): void { + respondEvents(response, [ + modelChunk( + { + role: 'assistant', + tool_calls: [ + { + index: 0, + id: `call-${name}`, + type: 'function', + function: { name, arguments: JSON.stringify(args) }, + }, + ], + }, + null, + ), + modelChunk({}, 'tool_calls'), + ]); +} + +async function readRequest(request: IncomingMessage): Promise { + let text = ''; + for await (const chunk of request) text += String(chunk); + return text; +} diff --git a/packages/cli/src/__tests__/acp-goal-plan-operations.test.ts b/packages/cli/src/__tests__/acp-goal-plan-operations.test.ts new file mode 100644 index 0000000000..cf7f31f910 --- /dev/null +++ b/packages/cli/src/__tests__/acp-goal-plan-operations.test.ts @@ -0,0 +1,176 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { RequestError } from '@agentclientprotocol/sdk'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; +import { AcpGoalPlanOperations, type GoalPlanOperationPort } from '../acp/goal-plan-operations.js'; +import type { AcpLoadContext, AcpSessionRegistryConnection } from '../acp/session-registry.js'; +import { AcpAdmittedTurnObservation } from '../acp/turn-observation.js'; + +const context: AcpLoadContext = { signal: new AbortController().signal, notify: async () => {} }; + +test('dispatched Goal arm keeps its observation and never infers success from equal content', async () => { + let requests = 0; + let commits = 0; + let rollbacks = 0; + const operations = new AcpGoalPlanOperations( + port( + async () => { + requests += 1; + throw new RuntimeHostRequestInterruptedError( + 'goal.arm', + 'control', + 'dispatched', + 'connection_lost', + ); + }, + () => { + commits += 1; + }, + () => { + rollbacks += 1; + }, + ), + ); + await assert.rejects( + operations.goalArm( + { sessionId: 'session-1', condition: 'Finish', maxIterations: null, tokenBudget: null }, + context, + ), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string; dispatch?: string }).code === 'outcome_unknown' && + (error.data as { dispatch?: string }).dispatch === 'dispatched', + ); + assert.deepEqual([requests, commits, rollbacks], [1, 1, 0]); +}); + +test('Plan start preserves the caller turnId on a lost result and queries admission once', async () => { + let requests = 0; + let reconciliations = 0; + let commits = 0; + const observation = new AcpAdmittedTurnObservation({ + sessionId: 'session-1', + turnId: 'turn-1', + notify: async () => {}, + }); + const operations = new AcpGoalPlanOperations( + port( + async () => { + requests += 1; + throw new RuntimeHostRequestInterruptedError( + 'plan.turn.start', + 'command', + 'dispatched', + 'connection_lost', + ); + }, + () => { + commits += 1; + }, + () => assert.fail('must retain observation'), + observation, + () => { + reconciliations += 1; + }, + ), + ); + await assert.rejects( + operations.planTurnStart( + { + kind: 'approve_proposal', + sessionId: 'session-1', + proposalId: 'proposal-1', + expectedRevision: 1, + expectedStoreVersion: 1, + turnId: 'turn-1', + }, + context, + ), + (error: unknown) => + error instanceof RequestError && (error.data as { turnId?: string }).turnId === 'turn-1', + ); + assert.deepEqual([requests, reconciliations, commits], [1, 1, 1]); + assert.equal(observation.admission.dispatchStarted, true); + observation.dispose(); +}); + +test('definite Plan control conflict rolls back without changing operation identity', async () => { + let rollbacks = 0; + const operations = new AcpGoalPlanOperations( + port( + async () => { + throw new RuntimeHostOperationError( + 'plan.control', + 'operation_conflict', + 'stale operation', + ); + }, + () => assert.fail('must not commit'), + () => { + rollbacks += 1; + }, + ), + ); + await assert.rejects( + operations.planControl( + { + kind: 'request_revision', + sessionId: 'session-1', + proposalId: 'proposal-1', + operationId: 'operation-1', + }, + context, + ), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string }).code === 'operation_conflict', + ); + assert.equal(rollbacks, 1); +}); + +function port( + request: () => Promise, + commit: () => void, + rollback: () => void, + observation?: AcpAdmittedTurnObservation, + reconcileAdmission?: () => void, +): GoalPlanOperationPort { + return { + prepare: async () => ({ + connection: { request } as unknown as AcpSessionRegistryConnection, + ...(observation ? { observation } : {}), + ...(reconcileAdmission ? { reconcileAdmission } : {}), + commit, + rollback, + }), + assertCurrent: () => {}, + mapError: (error, operation) => + RequestError.internalError({ + source: 'runtime_host', + operation, + code: error instanceof RuntimeHostOperationError ? error.code : 'internal_failure', + }), + }; +} diff --git a/packages/cli/src/__tests__/acp-session-domain-observation.test.ts b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts new file mode 100644 index 0000000000..68ca08bb47 --- /dev/null +++ b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts @@ -0,0 +1,119 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { waitFor } from '@maka/core/test-only/async-primitives'; +import type { PlanQueryResult } from '@maka/runtime-host/protocol'; +import { AcpSessionDomainObservation } from '../acp/session-domain-observation.js'; + +test('canonical Plan replacement rereads even when Goal is unchanged and rejects an old page', async () => { + const reads: Array<(result: PlanQueryResult) => void> = []; + const plans: number[] = []; + const goals: unknown[] = []; + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: () => + new Promise((resolve) => { + reads.push(resolve); + }), + goalNotify: () => async (status) => { + goals.push(status); + }, + planNotify: () => async (status) => { + plans.push(status.storeVersion); + }, + }); + observer.initialize(null); + assert.equal(reads.length, 1); + observer.canonicalReplacement(null); + reads[0]!(page(9)); + await waitFor(() => reads.length === 2); + assert.deepEqual(plans, []); + reads[1]!(page(1)); + await waitFor(() => plans.length === 1); + assert.deepEqual(plans, [1]); + assert.equal(goals.length, 2, 'new canonical epoch replays the Goal projection'); + observer.dispose(); +}); + +test('Plan invalidations coalesce during one read and dispose fences late delivery', async () => { + const reads: Array<(result: PlanQueryResult) => void> = []; + const plans: number[] = []; + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: () => + new Promise((resolve) => { + reads.push(resolve); + }), + goalNotify: () => undefined, + planNotify: () => async (status) => { + plans.push(status.storeVersion); + }, + }); + observer.planChanged(); + observer.planChanged(); + observer.planChanged(); + assert.equal(reads.length, 1); + reads[0]!(page(1)); + await waitFor(() => reads.length === 2); + reads[1]!(page(2)); + await waitFor(() => plans.includes(2)); + assert.deepEqual(plans, [1, 2]); + observer.planChanged(); + await waitFor(() => reads.length === 3); + observer.dispose(); + reads[2]!(page(3)); + await Promise.resolve(); + assert.deepEqual(plans, [1, 2]); +}); + +test('failed initial Plan refresh retries without inventing a state notification', async () => { + let reads = 0; + const plans: number[] = []; + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => { + reads += 1; + if (reads === 1) throw new Error('temporary read failure'); + return page(4); + }, + goalNotify: () => undefined, + planNotify: () => async (status) => { + plans.push(status.storeVersion); + }, + }); + observer.planChanged(); + await waitFor(() => plans.length === 1); + assert.deepEqual(plans, [4]); + assert.equal(reads, 2); + observer.dispose(); +}); + +function page(storeVersion: number): PlanQueryResult { + return { + kind: 'page', + sessionId: 'session-1', + storeVersion, + latestProposalId: null, + activeExecutionId: null, + items: [], + nextCursor: null, + }; +} diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index 538b8daf0b..a631b4602d 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -659,6 +659,7 @@ describe('Maka ACP stdio server', () => { agentCapabilities: { loadSession: true, sessionCapabilities: { list: {}, resume: {}, close: {} }, + _meta: { '_maka/goalPlan': { version: 1 } }, }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: '0.2.0' }, diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index 90305674ef..c39a0c1b0a 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -63,6 +63,8 @@ or reconnection without waiting for the Host to become available. | Tool `permission` | Standard `session/request_permission`. One-shot allow/deny choices are preserved; eligible tool permissions also expose an explicit allow-for-this-Turn choice. Permission cancellation cancels the Turn. | | Load/resume | `session/load` replays durable user, assistant, thinking and tool rows before returning; `session/resume` attaches without replay. Both return current configuration and leave the Session attached for prompt. Neither restarts an interrupted Turn. | | Explicit interrupted Turn resume | `_maka/turn/resume` queries the Host safety plan and starts only a ready plan. A required MCP tool absent from the current Session binding leaves the plan parked. A parked plan is returned unchanged. A lost dispatched start returns `outcome_unknown` with the exact `turnId`; the adapter never retries that command. | +| Goal control | `_maka/goal/query`, `_maka/goal/arm`, and `_maka/goal/control` forward Host projections and exact Goal identity, revision, iteration and token budgets. Arm stores a Goal; a normal user prompt begins its first work Turn. Resume can drive background work without another prompt. | +| Plan control | `_maka/plan/query` returns Host pages and `revision_changed`; `_maka/plan/control` forwards five actions with the caller's operation ID. `_maka/plan/turn/start` performs one Host approval or execution resume admission and returns `{plan, turn}`. Standard Session output and interaction notifications continue; `_maka/turn/status` reports a non-prompt Turn terminal state. | | Copy source discovery | `_maka/session/copy-source/query` returns a bounded Host Turn page and `expectedSourceRevision` for an owned Session, so branch/revision parameters can be obtained entirely through ACP. | | Branch and revision | `_maka/session/branch/create`, `_maka/session/revision/create`, and `_maka/session/revision/abandon` map to the corresponding Host commands. The source must be owned by this ACP connection. A committed target becomes immediately usable; `retained` keeps its ownership and `abandoned` releases local resources. | | Replacing all MCP configuration | Every load/resume applies its complete stdio list through the existing Session MCP manager and publication. An omitted `session/resume.mcpServers` means an empty list. Equivalent normalized configuration reuses the process; changing or clearing it republishes the Session scope. An attached Session rejects a different configuration while the Host reports an active Turn; retry after that Turn settles. | @@ -93,6 +95,37 @@ consumption, including when a successor finishes before an output barrier releas its observation. Ordinary ACP clients can load/resume and prompt without this extension. +## Goal and Plan extension + +The agent advertises `initialize.agentCapabilities._meta["_maka/goalPlan"]: +{ "version": 1 }`. All six requests require Session ownership obtained through +`session/new`, `session/load`, or `session/resume`. They use the Runtime Host +input and result shapes and preserve Goal budgets/revisions, Plan store versions, +entity IDs, operation IDs, and Turn IDs. Plan pages contain at most 16 items; +carry `storeVersion` and `nextCursor` into `list_continue`, and restart from +`list_start` after `revision_changed`. + +Clients that set `initialize.clientCapabilities._meta["_maka/goalPlanStatus"]: +true` also receive `_maka/goal/status` with `{sessionId, goal}` and +`_maka/plan/changed` with `{sessionId, storeVersion, latestProposalId, +activeExecutionId}`. These are latest-state hints, not an audit log. Query Plan +for the full proposal, execution, and step projection. A client without the +notification preference can still use every request. Register notification +handlers before sending mutations: a Host change can precede its request result. + +Successful mutation responses mean Host admission, not task completion. A lost +dispatched response returns `error.data.code: outcome_unknown` with the original +Session and available entity/operation/Turn identity. The adapter never resends +the command. Query the Host before a new user decision; equal Goal condition and +budget do not identify a prior arm. Host `persistence_failed` remains distinct +and keeps the observation alive for later authoritative facts. + +`goal.control.pause` controls Goal continuation, while `session/cancel` stops +the current observed Turn. `plan.control.cancel_execution` changes Plan state; +the Host can reject it with `session_busy` while a root Turn is active. Closing +the ACP Session releases this connection's resources and does not clear the +durable Goal or Plan. + The working directory in load/resume must resolve to the Session's Host cwd; additional directories are not supported. Missing and archived Sessions are rejected. A repeated successful load replays history again on the same retained diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index d0a096dba6..f913fe9f1b 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,59 @@ # ACP validation record +## PR8 Goal/Plan execution — September 26, 2026 + +Implementation baseline: official `apache/maka` main at +`87fc9f69cd11648f31048c1b633bb813aca5f515` (PR6 #5621 included). +PR7 #5685 was still open when checked. The ACP SDK remains pinned to 1.4.0; +Host wire schema and compatibility epoch were not changed. The design baseline is +`docs/architecture/acp-pr8-goal-plan-design.zh-CN.md`. + +Six typed extension requests are available: `_maka/goal/query`, +`_maka/goal/arm`, `_maka/goal/control`, `_maka/plan/query`, +`_maka/plan/control`, and `_maka/plan/turn/start`. The agent advertises +`_meta["_maka/goalPlan"]: {version: 1}`. Client +`_meta["_maka/goalPlanStatus"]: true` enables `_maka/goal/status` and +`_maka/plan/changed`; `_maka/turnStatus` separately enables the existing +non-prompt Turn terminal notification. Host decoders, ownership, attachment, +interaction, Turn observation, transcript and MCP paths are reused. + +The real-process tests use the official ACP SDK, stdio child server, real Host, +local controlled model service, and no external model API key. They prove that +arm alone invokes no model; a user prompt starts Goal work and Host continuation +arrives without another prompt; Goal resume starts work without a prompt; +external Host control updates Goal and Plan notifications; a model submits a +Plan through `SubmitPlan`; approval and interrupted execution resume each use +one `plan.turn.start` request; pending `AskUserQuestion` uses ACP elicitation; +the model advances execution through `update_plan`; terminal status follows +standard output; a repeated `turnId` does not run the model or add a second +terminal notification. Seventeen model-generated proposals exercise Plan paging, +and an external mutation makes an old cursor return `revision_changed`. An +unowned Session query fails before Host I/O. + +Focused operation tests prove a dispatched lost Goal arm/Plan start is not +resent, retains exact identity and observation, and an explicit Host conflict +rolls back. Domain observer tests cover coalesced invalidations, an unchanged +Goal across canonical Plan replacement, stale read rejection, retry after a +failed initial refresh, and disposal fencing. The existing ACP and Host suites +cover cancel, close, EOF, subscription recovery, pending permissions/forms, +and single-consumer barriers. Further PR8-specific race coverage is described +in the design's remaining verification notes. + +| Check | Result | +| --- | --- | +| Baseline CLI build and ACP/Registry tests | Passed: 152 tests before implementation. | +| Root build and typecheck | Passed on the implementation worktree. | +| ACP-focused dist tests | Passed: 299, 0 failed. | +| Full CLI dist suite | Passed: 1266 passed, 3 skipped, 0 failed on the final code. | +| Full Runtime Host dist suite | Passed: 2124 passed, 12 skipped, 0 failed. | +| Lint, format, ASF headers, CLI notices, diff whitespace | Passed. | +| Protocol epoch guard against `upstream/main` | Passed: no Host protocol change (epoch 189). | + +Desktop Electron E2E, Windows/Linux platform CI, Zed smoke, and real external +model services were not run locally. No GitHub issue comment, merge or +deployment was performed in local validation. + ## PR6 adjacent restore and teardown review fixes — September 24, 2026 Starting from PR #5621 head `d05fbb25f`, formal registry regressions were added diff --git a/packages/cli/src/acp/goal-plan-operations.ts b/packages/cli/src/acp/goal-plan-operations.ts new file mode 100644 index 0000000000..4ba1917ed9 --- /dev/null +++ b/packages/cli/src/acp/goal-plan-operations.ts @@ -0,0 +1,249 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RequestError } from '@agentclientprotocol/sdk'; +import { + RuntimeHostOperationError, + RuntimeHostRequestInterruptedError, +} from '@maka/runtime-host/client'; +import type { + GoalArmInput, + GoalArmResult, + GoalControlInput, + GoalControlResult, + GoalQueryInput, + GoalQueryResult, + PlanControlInput, + PlanControlResult, + PlanQueryInput, + PlanQueryResult, + PlanTurnStartInput, + PlanTurnStartResult, +} from '@maka/runtime-host/protocol'; +import type { AcpSessionRegistryConnection, AcpLoadContext } from './session-registry.js'; +import type { AcpAdmittedTurnObservation } from './turn-observation.js'; + +export interface PreparedGoalPlanOperation { + readonly connection: AcpSessionRegistryConnection; + readonly observation?: AcpAdmittedTurnObservation; + readonly terminalReplay?: boolean; + reconcileAdmission?(): void; + cancelObservation?(): void; + commit(): void; + rollback(error?: unknown): void; +} + +/** The Registry owns these resources; use cases receive only preparation and request ports. */ +export interface GoalPlanOperationPort { + prepare( + sessionId: string, + context: AcpLoadContext, + turnId?: string, + observe?: boolean, + ): Promise; + assertCurrent(sessionId: string): void; + mapError( + error: unknown, + operation: GoalPlanOperationName, + extra?: Record, + ): RequestError; +} + +export type GoalPlanOperationName = + | 'goal.query' + | 'goal.arm' + | 'goal.control' + | 'plan.query' + | 'plan.control' + | 'plan.turn.start'; + +export class AcpGoalPlanOperations { + readonly #port: GoalPlanOperationPort; + constructor(port: GoalPlanOperationPort) { + this.#port = port; + } + + goalQuery(input: GoalQueryInput, context: AcpLoadContext): Promise { + return this.#run('goal.query', input, context, !!context.notifyGoalStatus); + } + goalArm(input: GoalArmInput, context: AcpLoadContext): Promise { + return this.#run('goal.arm', input, context, true); + } + goalControl(input: GoalControlInput, context: AcpLoadContext): Promise { + return this.#run('goal.control', input, context, true); + } + planQuery(input: PlanQueryInput, context: AcpLoadContext): Promise { + return this.#run('plan.query', input, context, !!context.notifyPlanChanged); + } + planControl(input: PlanControlInput, context: AcpLoadContext): Promise { + return this.#run('plan.control', input, context, true); + } + + async planTurnStart( + input: PlanTurnStartInput, + context: AcpLoadContext, + ): Promise { + const prepared = await this.#port.prepare(input.sessionId, context, input.turnId, true); + let dispatched = false; + const onAbort = () => prepared.cancelObservation?.(); + context.signal.addEventListener('abort', onAbort, { once: true }); + if (context.signal.aborted) onAbort(); + try { + context.signal.throwIfAborted(); + this.#port.assertCurrent(input.sessionId); + const observation = prepared.observation; + if (!observation && !prepared.terminalReplay) + throw new Error('Plan Turn observer was not installed'); + observation?.markDispatched(); + dispatched = true; + const result = await prepared.connection.request('plan.turn.start', input); + observation?.settleStartRequest(result.turn); + prepared.commit(); + return result; + } catch (error) { + if (dispatched && isDispatchedUnknown(error)) { + prepared.observation?.failStartRequest(error); + prepared.reconcileAdmission?.(); + prepared.commit(); + throw RequestError.internalError( + { + source: 'runtime_host', + operation: 'plan.turn.start', + code: 'outcome_unknown', + dispatch: 'dispatched', + sessionId: input.sessionId, + turnId: input.turnId, + ...(input.kind === 'approve_proposal' + ? { proposalId: input.proposalId } + : { executionId: input.executionId }), + }, + 'Plan Turn start outcome is unknown; query the Plan and exact Turn before retrying', + ); + } + if (dispatched && isPersistenceUnknown(error)) { + prepared.observation?.failStartRequest(error); + prepared.reconcileAdmission?.(); + prepared.commit(); + throw this.#port.mapError(error, 'plan.turn.start', { + sessionId: input.sessionId, + turnId: input.turnId, + }); + } + prepared.rollback(error); + if (error instanceof RequestError) throw error; + throw this.#port.mapError(error, 'plan.turn.start', { + sessionId: input.sessionId, + turnId: input.turnId, + }); + } finally { + context.signal.removeEventListener('abort', onAbort); + } + } + + async #run( + operation: 'goal.query', + input: GoalQueryInput, + context: AcpLoadContext, + observe: boolean, + ): Promise; + async #run( + operation: 'goal.arm', + input: GoalArmInput, + context: AcpLoadContext, + observe: boolean, + ): Promise; + async #run( + operation: 'goal.control', + input: GoalControlInput, + context: AcpLoadContext, + observe: boolean, + ): Promise; + async #run( + operation: 'plan.query', + input: PlanQueryInput, + context: AcpLoadContext, + observe: boolean, + ): Promise; + async #run( + operation: 'plan.control', + input: PlanControlInput, + context: AcpLoadContext, + observe: boolean, + ): Promise; + async #run( + operation: GoalPlanOperationName, + input: GoalQueryInput | GoalArmInput | GoalControlInput | PlanQueryInput | PlanControlInput, + context: AcpLoadContext, + observe: boolean, + ): Promise< + GoalQueryResult | GoalArmResult | GoalControlResult | PlanQueryResult | PlanControlResult + > { + const prepared = await this.#port.prepare(input.sessionId, context, undefined, observe); + let dispatched = false; + try { + context.signal.throwIfAborted(); + this.#port.assertCurrent(input.sessionId); + dispatched = true; + // The Host protocol validates both request and response against the operation spec. + const result = await ( + prepared.connection.request as ( + name: GoalPlanOperationName, + value: typeof input, + ) => Promise< + GoalQueryResult | GoalArmResult | GoalControlResult | PlanQueryResult | PlanControlResult + > + )(operation, input); + prepared.commit(); + return result; + } catch (error) { + if (dispatched && isDispatchedUnknown(error)) { + prepared.commit(); + throw RequestError.internalError( + { + source: 'runtime_host', + operation, + code: 'outcome_unknown', + dispatch: 'dispatched', + sessionId: input.sessionId, + ...('goalId' in input ? { goalId: input.goalId } : {}), + ...('operationId' in input ? { operationId: input.operationId } : {}), + ...('proposalId' in input ? { proposalId: input.proposalId } : {}), + ...('executionId' in input ? { executionId: input.executionId } : {}), + }, + 'Runtime Host operation outcome is unknown; query authoritative state before retrying', + ); + } + if (dispatched && isPersistenceUnknown(error)) { + prepared.commit(); + throw this.#port.mapError(error, operation, { sessionId: input.sessionId }); + } + prepared.rollback(error); + if (error instanceof RequestError) throw error; + throw this.#port.mapError(error, operation, { sessionId: input.sessionId }); + } + } +} + +function isDispatchedUnknown(error: unknown): boolean { + return error instanceof RuntimeHostRequestInterruptedError && error.dispatch === 'dispatched'; +} + +function isPersistenceUnknown(error: unknown): boolean { + return error instanceof RuntimeHostOperationError && error.code === 'persistence_failed'; +} diff --git a/packages/cli/src/acp/goal-plan-routes.ts b/packages/cli/src/acp/goal-plan-routes.ts new file mode 100644 index 0000000000..ef261b4f70 --- /dev/null +++ b/packages/cli/src/acp/goal-plan-routes.ts @@ -0,0 +1,40 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { RequestError } from '@agentclientprotocol/sdk'; +import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; +import type { GoalPlanOperationName } from './goal-plan-operations.js'; + +/** Keep ACP extension parsing identical to the Host's current wire contract. */ +export function goalPlanRouteInput(operation: K) { + return { + parse(value: unknown): ReturnType<(typeof HOST_OPERATION_SPECS)[K]['decodeInput']> { + try { + return HOST_OPERATION_SPECS[operation].decodeInput(value) as ReturnType< + (typeof HOST_OPERATION_SPECS)[K]['decodeInput'] + >; + } catch { + throw RequestError.invalidParams( + { reason: 'invalid_goal_plan_input', operation }, + `Invalid ${operation} request`, + ); + } + }, + }; +} diff --git a/packages/cli/src/acp/maka-acp-agent.ts b/packages/cli/src/acp/maka-acp-agent.ts index 670e8f2142..af293ecda1 100644 --- a/packages/cli/src/acp/maka-acp-agent.ts +++ b/packages/cli/src/acp/maka-acp-agent.ts @@ -26,6 +26,7 @@ import { type ClientCapabilities, } from '@agentclientprotocol/sdk'; import { HOST_OPERATION_SPECS } from '@maka/runtime-host/protocol'; +import { goalPlanRouteInput } from './goal-plan-routes.js'; import type { AcpLoadContext, AcpSessionRegistry } from './session-registry.js'; export interface MakaAcpAgentOptions { @@ -36,6 +37,12 @@ export interface MakaAcpAgentOptions { | 'load' | 'resume' | 'resumeTurn' + | 'goalQuery' + | 'goalArm' + | 'goalControl' + | 'planQuery' + | 'planControl' + | 'planTurnStart' | 'queryCopySource' | 'branch' | 'createRevision' @@ -58,6 +65,7 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { agentCapabilities: { loadSession: true, sessionCapabilities: { list: {}, resume: {}, close: {} }, + _meta: { '_maka/goalPlan': { version: 1 } }, }, authMethods: [], agentInfo: { name: 'maka', title: 'Maka', version: options.version }, @@ -96,6 +104,51 @@ export function createMakaAcpAgent(options: MakaAcpAgentOptions): AgentApp { sessionContext(client, signal, clientCapabilities, true), ), ) + .onRequest('_maka/goal/query', goalPlanRouteInput('goal.query'), ({ params, signal, client }) => + options.sessionRegistry.goalQuery( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest('_maka/goal/arm', goalPlanRouteInput('goal.arm'), ({ params, signal, client }) => + options.sessionRegistry.goalArm( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest( + '_maka/goal/control', + goalPlanRouteInput('goal.control'), + ({ params, signal, client }) => + options.sessionRegistry.goalControl( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest('_maka/plan/query', goalPlanRouteInput('plan.query'), ({ params, signal, client }) => + options.sessionRegistry.planQuery( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest( + '_maka/plan/control', + goalPlanRouteInput('plan.control'), + ({ params, signal, client }) => + options.sessionRegistry.planControl( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) + .onRequest( + '_maka/plan/turn/start', + goalPlanRouteInput('plan.turn.start'), + ({ params, signal, client }) => + options.sessionRegistry.planTurnStart( + params, + sessionContext(client, signal, clientCapabilities, true), + ), + ) .onRequest( '_maka/session/copy-source/query', { @@ -196,5 +249,15 @@ function sessionContext( ) => client.notify('_maka/turn/status', status), } : {}), + ...(capabilities._meta?.['_maka/goalPlanStatus'] === true + ? { + notifyGoalStatus: ( + status: Parameters>[0], + ) => client.notify('_maka/goal/status', status), + notifyPlanChanged: ( + status: Parameters>[0], + ) => client.notify('_maka/plan/changed', status), + } + : {}), }; } diff --git a/packages/cli/src/acp/session-domain-observation.ts b/packages/cli/src/acp/session-domain-observation.ts new file mode 100644 index 0000000000..7617bc10b8 --- /dev/null +++ b/packages/cli/src/acp/session-domain-observation.ts @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { GoalProjection, PlanQueryResult } from '@maka/runtime-host/protocol'; + +export interface AcpPlanChanged { + readonly sessionId: string; + readonly storeVersion: number; + readonly latestProposalId: string | null; + readonly activeExecutionId: string | null; +} + +export interface AcpGoalStatus { + readonly sessionId: string; + readonly goal: GoalProjection | null; +} + +/** Latest authoritative domain state for one retained Session attachment. */ +export class AcpSessionDomainObservation { + readonly #sessionId: string; + readonly #queryPlan: () => Promise; + readonly #goalNotify: () => ((status: AcpGoalStatus) => Promise) | undefined; + readonly #planNotify: () => ((status: AcpPlanChanged) => Promise) | undefined; + #disposed = false; + #epoch = 0; + #goal: GoalProjection | null = null; + #goalKnown = false; + #goalDelivered?: string; + #planDelivered?: number; + #planSeen?: number; + #planDirty = false; + #planRefresh?: Promise; + #planRetryCount = 0; + #planRetryTimer?: ReturnType; + #goalDelivery?: Promise; + #planDelivery?: Promise; + #pendingGoal?: AcpGoalStatus; + #pendingPlan?: AcpPlanChanged; + + constructor(options: { + sessionId: string; + queryPlan: () => Promise; + goalNotify: () => ((status: AcpGoalStatus) => Promise) | undefined; + planNotify: () => ((status: AcpPlanChanged) => Promise) | undefined; + }) { + this.#sessionId = options.sessionId; + this.#queryPlan = options.queryPlan; + this.#goalNotify = options.goalNotify; + this.#planNotify = options.planNotify; + } + + initialize(goal: GoalProjection | null): void { + this.goalChanged(goal); + this.planChanged(); + } + + goalChanged(goal: GoalProjection | null): void { + if (this.#disposed) return; + if ( + this.#goalKnown && + goal?.goalId === this.#goal?.goalId && + (goal?.revision ?? -1) < (this.#goal?.revision ?? -1) + ) + return; + this.#goalKnown = true; + this.#goal = goal; + const status = { sessionId: this.#sessionId, goal }; + const key = JSON.stringify(status); + if (key !== this.#goalDelivered) { + this.#pendingGoal = status; + this.#sendGoal(); + } + } + + planChanged(): void { + if (this.#disposed) return; + if (!this.#planNotify()) return; + if (this.#planRetryTimer) { + clearTimeout(this.#planRetryTimer); + this.#planRetryTimer = undefined; + } + this.#planRetryCount = 0; + this.#planDirty = true; + this.#kickPlan(); + } + + #kickPlan(): void { + if (!this.#planRefresh) { + this.#planRefresh = this.#refreshPlan().finally(() => { + this.#planRefresh = undefined; + if (this.#planDirty && !this.#disposed && !this.#planRetryTimer && this.#planRetryCount < 4) + this.#kickPlan(); + }); + } + } + + canonicalReplacement(goal: GoalProjection | null): void { + this.#epoch += 1; + this.#goalDelivered = undefined; + this.#planDelivered = undefined; + this.#planSeen = undefined; + this.#goalKnown = false; + this.initialize(goal); + } + + dispose(): void { + this.#disposed = true; + this.#epoch += 1; + this.#pendingGoal = undefined; + this.#pendingPlan = undefined; + this.#planDirty = false; + if (this.#planRetryTimer) clearTimeout(this.#planRetryTimer); + this.#planRetryTimer = undefined; + } + + async #refreshPlan(): Promise { + // A frame received during a read is folded into one follow-up read. + while (this.#planDirty && !this.#disposed) { + this.#planDirty = false; + const epoch = this.#epoch; + try { + const result = await this.#queryPlan(); + if (this.#disposed || epoch !== this.#epoch || result.kind !== 'page') continue; + this.#planRetryCount = 0; + if (this.#planSeen !== undefined && result.storeVersion < this.#planSeen) continue; + this.#planSeen = result.storeVersion; + const status: AcpPlanChanged = { + sessionId: this.#sessionId, + storeVersion: result.storeVersion, + latestProposalId: result.latestProposalId, + activeExecutionId: result.activeExecutionId, + }; + if (result.storeVersion !== this.#planDelivered) { + this.#pendingPlan = status; + this.#sendPlan(); + } + } catch (error) { + console.error('[acp] Plan status refresh failed:', error); + this.#planDirty = true; + this.#planRetryCount += 1; + if (this.#planRetryCount <= 3 && !this.#disposed) { + this.#planRetryTimer = setTimeout( + () => { + this.#planRetryTimer = undefined; + this.#kickPlan(); + }, + 25 * 2 ** (this.#planRetryCount - 1), + ); + } + break; + } + } + } + + #sendGoal(): void { + if (this.#goalDelivery || !this.#pendingGoal || this.#disposed) return; + const notify = this.#goalNotify(); + if (!notify) return; + const status = this.#pendingGoal; + const epoch = this.#epoch; + this.#pendingGoal = undefined; + this.#goalDelivery = notify(status) + .then(() => { + if (!this.#disposed && epoch === this.#epoch) this.#goalDelivered = JSON.stringify(status); + }) + .catch((error: unknown) => console.error('[acp] Goal status delivery failed:', error)) + .finally(() => { + this.#goalDelivery = undefined; + this.#sendGoal(); + }); + } + + #sendPlan(): void { + if (this.#planDelivery || !this.#pendingPlan || this.#disposed) return; + const notify = this.#planNotify(); + if (!notify) return; + const status = this.#pendingPlan; + const epoch = this.#epoch; + this.#pendingPlan = undefined; + this.#planDelivery = notify(status) + .then(() => { + if (!this.#disposed && epoch === this.#epoch) this.#planDelivered = status.storeVersion; + }) + .catch((error: unknown) => console.error('[acp] Plan status delivery failed:', error)) + .finally(() => { + this.#planDelivery = undefined; + this.#sendPlan(); + }); + } +} diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index e9fc3becfe..354c641370 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -70,6 +70,18 @@ import { type SessionRevisionAbandonResult, type SessionTurnsQueryInput, type SessionTurnsQueryResult, + type GoalQueryInput, + type GoalQueryResult, + type GoalArmInput, + type GoalArmResult, + type GoalControlInput, + type GoalControlResult, + type PlanQueryInput, + type PlanQueryResult, + type PlanControlInput, + type PlanControlResult, + type PlanTurnStartInput, + type PlanTurnStartResult, } from '@maka/runtime-host/protocol'; import { RuntimeHostSessionChannel } from '../runtime-host-session-channel.js'; import { @@ -89,6 +101,16 @@ import { mapAcpPromptContent, publishAcpPromptAttachments } from './prompt-conte import { AcpSessionMcp, createAcpMcpConfig, type AcpMcpConnection } from './session-mcp.js'; import { AcpSessionInteractions, type AcpInteractionClient } from './session-interactions.js'; import { AcpTurnObservation, AcpAdmittedTurnObservation } from './turn-observation.js'; +import { + AcpGoalPlanOperations, + type GoalPlanOperationName, + type PreparedGoalPlanOperation, +} from './goal-plan-operations.js'; +import { + AcpSessionDomainObservation, + type AcpGoalStatus, + type AcpPlanChanged, +} from './session-domain-observation.js'; const ACP_SESSION_CURSOR_MAX_BYTES = 8 * 1024; const ADMISSION_QUERY_MAX_ATTEMPTS = 5; @@ -121,7 +143,8 @@ type AcpSessionRegistryOperation = | 'session.turns.query' | 'session.branch.create' | 'session.revision.create' - | 'session.revision.abandon'; + | 'session.revision.abandon' + | GoalPlanOperationName; type AcpSessionRegistryLifecycleOperation = | 'connect' | 'session.close' @@ -154,6 +177,8 @@ export interface AcpAttachedTurnStatus { export interface AcpLoadContext extends AcpPromptContext { readonly notifyTurnStatus?: (status: AcpAttachedTurnStatus) => Promise; + readonly notifyGoalStatus?: (status: AcpGoalStatus) => Promise; + readonly notifyPlanChanged?: (status: AcpPlanChanged) => Promise; } export interface AcpSessionRegistryOptions { @@ -196,12 +221,18 @@ export class AcpSessionRegistry { readonly #mcps = new Map(); readonly #creationAbort = new AbortController(); readonly #attachmentInteractions = new Map(); + readonly #domainObservations = new Map(); + readonly #goalPlan: AcpGoalPlanOperations; readonly #attachments = new Map>(); readonly #attachmentOpenControllers = new Map(); readonly #attachmentConfigurations = new Map(); readonly #pendingConfigSets = new Map>>(); readonly #attachmentWaiters = new Map>(); readonly #turnObservations = new Map>(); + readonly #pendingPlanAdmissions = new Map< + AcpAdmittedTurnObservation, + { users: number; retained: boolean } + >(); readonly #discardedAttachments = new WeakSet(); readonly #externalObservationContexts = new Map(); readonly #externalContextLeases = new Map(); @@ -222,6 +253,169 @@ export class AcpSessionRegistry { this.#connect = options.connect; this.#newSessionId = options.newSessionId ?? randomUUID; this.#newTurnId = options.newTurnId ?? randomUUID; + this.#goalPlan = new AcpGoalPlanOperations({ + prepare: (sessionId, context, turnId, observe) => + this.#prepareGoalPlan(sessionId, context, turnId, observe), + assertCurrent: (sessionId) => { + this.#assertOpen('subscription.open'); + this.#assertOwned(sessionId); + }, + mapError: (error, operation, extra) => requestErrorFromRuntimeHost(error, operation, extra), + }); + } + + goalQuery(input: GoalQueryInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.goalQuery(input, context)); + } + goalArm(input: GoalArmInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.goalArm(input, context)); + } + goalControl(input: GoalControlInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.goalControl(input, context)); + } + planQuery(input: PlanQueryInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.planQuery(input, context)); + } + planControl(input: PlanControlInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.planControl(input, context)); + } + planTurnStart(input: PlanTurnStartInput, context: AcpLoadContext): Promise { + return this.#track(this.#goalPlan.planTurnStart(input, context)); + } + + async #prepareGoalPlan( + sessionId: string, + context: AcpLoadContext, + turnId?: string, + observe = true, + ): Promise { + this.#assertOpen('subscription.open'); + this.#assertOwned(sessionId); + context.signal.throwIfAborted(); + const generation = this.#sessionCloseGenerations.get(sessionId) ?? 0; + const connection = await this.#getConnection('subscription.open'); + this.#assertOwned(sessionId); + context.signal.throwIfAborted(); + if (!observe) return { connection, commit: () => undefined, rollback: () => undefined }; + const restoreContext = this.#installExternalContext(sessionId, context); + const restoreClient = this.#attachmentInteractions + .get(sessionId) + ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); + const creatingAttachment = !this.#attachments.has(sessionId); + let observation: AcpAdmittedTurnObservation | undefined; + let terminalReplay = false; + let admissionFinished = false; + let attachment: RuntimeHostSessionChannel | undefined; + const finishAdmission = (retain: boolean, error?: unknown) => { + if (!observation || admissionFinished) return; + admissionFinished = true; + const state = this.#pendingPlanAdmissions.get(observation); + if (!state) return; + state.retained ||= retain; + state.users -= 1; + if (state.users > 0) return; + this.#pendingPlanAdmissions.delete(observation); + if (state.retained) return; + observation.failStartRequest(error); + observation.dispose(); + this.#removeTurnObservation(observation); + if (error && turnId) attachment?.failTurn(turnId, error); + }; + const rollback = (error?: unknown) => { + finishAdmission(false, error); + restoreContext.rollback(); + restoreClient?.rollback(); + if ( + creatingAttachment && + !this.#hasAttachmentConsumers(sessionId) && + !this.#turnObservations.get(sessionId)?.size && + !this.#externalObservationContexts.has(sessionId) + ) { + const task = this.#detachAttachment(sessionId); + void task?.then( + (channel) => channel.close(), + () => undefined, + ); + } + }; + try { + const prepared = await this.#prepareExternalObservation( + sessionId, + context, + connection, + 'subscription.open', + turnId, + ); + attachment = prepared.attachment; + observation = prepared.observation; + terminalReplay = prepared.terminalReplay ?? false; + if (observation) { + const state = this.#pendingPlanAdmissions.get(observation) ?? { + users: 0, + retained: false, + }; + state.users += 1; + this.#pendingPlanAdmissions.set(observation, state); + } + if ((this.#sessionCloseGenerations.get(sessionId) ?? 0) !== generation) + throw unknownSessionError(); + if (context.notifyGoalStatus || context.notifyPlanChanged) { + this.#domainObservations.get(sessionId)?.initialize(attachment.snapshot.goal); + } + return { + connection, + ...(terminalReplay ? { terminalReplay } : {}), + ...(observation ? { observation } : {}), + ...(observation + ? { reconcileAdmission: () => this.#queryPromptAdmission(observation!, connection) } + : {}), + ...(observation + ? { + cancelObservation: () => { + const state = this.#pendingPlanAdmissions.get(observation!); + if (state?.users === 1 && !state.retained) + void this.#cancelPrompt(observation!).catch(() => undefined); + }, + } + : {}), + commit: () => { + finishAdmission(true); + restoreContext.commit(); + restoreClient?.commit(); + }, + rollback, + }; + } catch (error) { + rollback(error); + if (error instanceof RequestError) throw error; + throw requestErrorFromRuntimeHost(error, 'subscription.open'); + } + } + + /** Shared non-prompt preparation for explicit Turn resume and Plan admission. */ + async #prepareExternalObservation( + sessionId: string, + context: AcpLoadContext, + connection: AcpSessionRegistryConnection, + operation: 'subscription.open' | 'turn.resume.start', + turnId?: string, + ): Promise<{ + attachment: RuntimeHostSessionChannel; + observation?: AcpAdmittedTurnObservation; + terminalReplay?: boolean; + }> { + await this.#mcps.get(sessionId)?.ready(context.signal); + const attachment = await this.#ensureAttachment(sessionId, connection, context); + context.signal.throwIfAborted(); + this.#assertOpen(operation); + this.#assertOwned(sessionId); + if (!turnId) return { attachment }; + const root = attachment.snapshot.rootTurn; + if (root?.turnId === turnId && isRuntimeHostTerminalTurn(root)) + return { attachment, terminalReplay: true }; + const observation = await this.#adoptTurn(sessionId, turnId, attachment, true); + if (!(observation instanceof AcpAdmittedTurnObservation)) throw registryClosedError(operation); + return { attachment, observation }; } async create(params: NewSessionRequest, signal?: AbortSignal): Promise { @@ -842,6 +1036,13 @@ export class AcpSessionRegistry { }, }); this.#attachmentInteractions.set(sessionId, interactions); + const domainObservation = new AcpSessionDomainObservation({ + sessionId, + queryPlan: () => connection.request('plan.query', { kind: 'list_start', sessionId }), + goalNotify: () => this.#externalObservationContexts.get(sessionId)?.notifyGoalStatus, + planNotify: () => this.#externalObservationContexts.get(sessionId)?.notifyPlanChanged, + }); + this.#domainObservations.set(sessionId, domainObservation); task = RuntimeHostSessionChannel.open({ connection, signal: openingController.signal, @@ -862,6 +1063,22 @@ export class AcpSessionRegistry { }); }, onRuntimeResourceChanged: () => undefined, + onSessionDomainChanged: (frame) => { + if ( + frame.domain === 'plan' && + this.#externalObservationContexts.get(sessionId)?.notifyPlanChanged + ) + domainObservation.planChanged(); + }, + onCanonicalReplacement: (snapshot) => { + if (attachment && this.#discardedAttachments.has(attachment)) return; + if ( + this.#externalObservationContexts.get(sessionId)?.notifyPlanChanged || + this.#externalObservationContexts.get(sessionId)?.notifyGoalStatus + ) { + domainObservation.canonicalReplacement(snapshot.goal); + } + }, onSnapshotChanged: (snapshot) => { if (attachment && this.#discardedAttachments.has(attachment)) return; this.#wakeSession(sessionId); @@ -937,7 +1154,7 @@ export class AcpSessionRegistry { ?.reconcile() .catch(() => undefined); }, - onGoalChanged: () => undefined, + onGoalChanged: (goal) => domainObservation.goalChanged(goal), onFailed: failAttachment, onRecovered: () => { for (const active of this.#admittedTurns(sessionId)) { @@ -986,9 +1203,18 @@ export class AcpSessionRegistry { ? attachedTurnId : undefined, ); + if ( + this.#externalObservationContexts.get(sessionId)?.notifyGoalStatus || + this.#externalObservationContexts.get(sessionId)?.notifyPlanChanged + ) { + domainObservation.initialize(channel.snapshot.goal); + } return channel; }) .catch((error: unknown) => { + domainObservation.dispose(); + if (this.#domainObservations.get(sessionId) === domainObservation) + this.#domainObservations.delete(sessionId); interactions.close(); if (this.#attachmentInteractions.get(sessionId) === interactions) { this.#attachmentInteractions.delete(sessionId); @@ -1042,6 +1268,8 @@ export class AcpSessionRegistry { this.#attachmentOpenControllers.get(sessionId)?.abort(); this.#attachmentInteractions.get(sessionId)?.close(); this.#attachmentInteractions.delete(sessionId); + this.#domainObservations.get(sessionId)?.dispose(); + this.#domainObservations.delete(sessionId); this.#attachmentConfigurations.delete(sessionId); this.#attachments.delete(sessionId); return expected; @@ -1267,14 +1495,16 @@ export class AcpSessionRegistry { }; context.signal.addEventListener('abort', onAbort, { once: true }); try { - await this.#mcps.get(params.sessionId)?.ready(context.signal); - attachment = await this.#ensureAttachment(params.sessionId, connection, context); - context.signal.throwIfAborted(); - this.#assertOwned(params.sessionId); - const adopted = await this.#adoptTurn(params.sessionId, turnId, attachment, true); - if (!(adopted instanceof AcpAdmittedTurnObservation)) - throw registryClosedError('turn.resume.start'); - observation = adopted; + const prepared = await this.#prepareExternalObservation( + params.sessionId, + context, + connection, + 'turn.resume.start', + turnId, + ); + attachment = prepared.attachment; + observation = prepared.observation; + if (!observation) throw registryClosedError('turn.resume.start'); if (context.signal.aborted) onAbort(); if (observation.cancelled) throw RequestError.internalError( @@ -1702,6 +1932,9 @@ export class AcpSessionRegistry { lifetime.throwIfAborted(); this.#assertOpen('subscription.open'); this.#assertOwned(params.sessionId); + if (context.notifyGoalStatus || context.notifyPlanChanged) { + this.#domainObservations.get(params.sessionId)?.initialize(attachment.snapshot.goal); + } if ((this.#sessionCloseGenerations.get(params.sessionId) ?? 0) !== generation) { throw unknownSessionError(); } @@ -2019,6 +2252,8 @@ export class AcpSessionRegistry { } async #dispose(): Promise { + for (const observer of this.#domainObservations.values()) observer.dispose(); + this.#domainObservations.clear(); this.#externalObservationContexts.clear(); this.#externalContextLeases.clear(); for (const observations of this.#turnObservations.values()) { @@ -2079,6 +2314,7 @@ export class AcpSessionRegistry { ...this.#inFlightOperations, ...configurations.map(({ tail }) => tail), ]); + this.#pendingPlanAdmissions.clear(); this.#ownedSessionIds.clear(); this.#historyReplayDelivery.clear(); } diff --git a/packages/cli/src/runtime-host-session-channel.ts b/packages/cli/src/runtime-host-session-channel.ts index 4b7ed07112..cc4e41ee74 100644 --- a/packages/cli/src/runtime-host-session-channel.ts +++ b/packages/cli/src/runtime-host-session-channel.ts @@ -106,6 +106,8 @@ export interface RuntimeHostSessionChannelOptions { * channel for goal state — the same one the desktop observer diffs. */ onGoalChanged: (goal: GoalProjection | null) => void; + /** Fired for a new authoritative subscription epoch, even if root/Goal are unchanged. */ + onCanonicalReplacement?: (snapshot: SessionContinuitySnapshot) => void; /** Optional read-only projection observer; the channel remains the sole folder. */ onSnapshotChanged?: (snapshot: SessionContinuitySnapshot) => void; /** Fired only after the channel's bounded recovery policy is exhausted. */ @@ -127,6 +129,7 @@ export class RuntimeHostSessionChannel { readonly #onTranscriptSettlement: (turnId: string) => void; readonly #onTranscriptReplaced: (turnId: string, messages: readonly StoredMessage[]) => void; readonly #onGoalChanged: (goal: GoalProjection | null) => void; + readonly #onCanonicalReplacement: ((snapshot: SessionContinuitySnapshot) => void) | undefined; readonly #onSnapshotChanged: ((snapshot: SessionContinuitySnapshot) => void) | undefined; readonly #onFailed: ((error: Error) => void) | undefined; readonly #onRecovered: () => void; @@ -174,6 +177,7 @@ export class RuntimeHostSessionChannel { this.#onTranscriptSettlement = options.onTranscriptSettlement; this.#onTranscriptReplaced = options.onTranscriptReplaced; this.#onGoalChanged = options.onGoalChanged; + this.#onCanonicalReplacement = options.onCanonicalReplacement; this.#onSnapshotChanged = options.onSnapshotChanged; this.#onFailed = options.onFailed; this.#onRecovered = options.onRecovered; @@ -733,6 +737,8 @@ export class RuntimeHostSessionChannel { this.#subscription.activeAssistantStreams, ); this.#onSnapshotChanged?.(structuredClone(this.#projector.snapshot)); + if (replacedLiveState) + this.#onCanonicalReplacement?.(structuredClone(this.#projector.snapshot)); // A canonical replacement is a sequence cut. No queued event from the // retired subscription may replay after the transcript/snapshot has // established newer state; active, terminal, and interaction state is From fbfe620f952ecdf3a397ed16941d304b84d851a1 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:25:14 +0800 Subject: [PATCH 2/5] fix(acp): preserve latest pending Goal status Coalesce every Goal update before checking delivery deduplication so a value returning to the last delivered snapshot cannot leave an older notification pending. Cover slow delivery, duplicate snapshots, epoch replacement, disposal, and Goal/Plan notification transport failure cleanup. Full CLI suite: 1275 passed, 3 skipped. Generated-by: Codex --- .../acp-session-domain-observation.test.ts | 156 +++++++++++++++++- .../src/__tests__/acp-stdio-server.test.ts | 109 ++++++++++++ .../cli/src/acp/session-domain-observation.ts | 14 +- 3 files changed, 270 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/__tests__/acp-session-domain-observation.test.ts b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts index 68ca08bb47..41717f96f3 100644 --- a/packages/cli/src/__tests__/acp-session-domain-observation.test.ts +++ b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts @@ -18,11 +18,142 @@ */ import assert from 'node:assert/strict'; +import { setImmediate as nextEventLoopTurn } from 'node:timers/promises'; import { test } from 'node:test'; -import { waitFor } from '@maka/core/test-only/async-primitives'; -import type { PlanQueryResult } from '@maka/runtime-host/protocol'; +import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; +import type { GoalProjection, PlanQueryResult } from '@maka/runtime-host/protocol'; import { AcpSessionDomainObservation } from '../acp/session-domain-observation.js'; +for (const withPendingClear of [false, true]) { + test(`latest null Goal survives an in-flight notification (pending clear: ${withPendingClear})`, async (t) => { + const delivery = deferred(); + const goals: Array = []; + const active = goal(); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + if (status.goal === active) await delivery.promise; + goals.push(status.goal); + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + observer.goalChanged(null); + await nextEventLoopTurn(); + assert.deepEqual(goals, [null]); + observer.goalChanged(active); + if (withPendingClear) observer.goalChanged(goal({ revision: 2, status: 'cleared' })); + // A Session retirement can remove the Goal after clear while the client is slow. + observer.goalChanged(null); + assert.deepEqual(goals, [null], 'only one notification may be in flight'); + delivery.resolve(); + await nextEventLoopTurn(); + assert.deepEqual(goals, [null, active, null]); + }); +} + +test('Goal delivery coalesces newer revisions and deduplicates only after delivery', async (t) => { + const delivery = deferred(); + const goals: Array = []; + const active = goal(); + const latest = goal({ revision: 3, status: 'cleared' }); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + if (goals.length === 0) await delivery.promise; + goals.push(status.goal); + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + observer.goalChanged(active); + observer.goalChanged(goal({ revision: 2, status: 'paused' })); + observer.goalChanged(latest); + observer.goalChanged(goal({ revision: 2, status: 'paused' })); + delivery.resolve(); + await nextEventLoopTurn(); + assert.deepEqual( + goals, + [active, latest], + 'stale revisions cannot overwrite the latest pending Goal', + ); + observer.goalChanged({ ...latest }); + await nextEventLoopTurn(); + assert.deepEqual(goals, [active, latest], 'already delivered snapshots are not repeated'); +}); + +test('duplicate Goal changes during delivery do not schedule a duplicate notification', async (t) => { + const delivery = deferred(); + const goals: Array = []; + const active = goal(); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + goals.push(status.goal); + await delivery.promise; + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + observer.goalChanged(active); + observer.goalChanged({ ...active }); + delivery.resolve(); + await nextEventLoopTurn(); + assert.deepEqual(goals, [active]); +}); + +test('a late Goal delivery cannot suppress the same snapshot in a new canonical epoch', async (t) => { + const delivery = deferred(); + const goals: Array = []; + const active = goal(); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + goals.push(status.goal); + if (goals.length === 1) await delivery.promise; + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + observer.goalChanged(active); + observer.canonicalReplacement({ ...active }); + delivery.resolve(); + await nextEventLoopTurn(); + assert.deepEqual(goals, [active, active]); +}); + +for (const rejectDelivery of [false, true]) { + test(`dispose fences a pending Goal after delivery ${rejectDelivery ? 'failure' : 'success'}`, async (t) => { + const delivery = deferred(); + const goals: Array = []; + const errors = t.mock.method(console, 'error', () => undefined); + const active = goal(); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + goals.push(status.goal); + await delivery.promise; + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + observer.goalChanged(active); + observer.goalChanged(null); + observer.dispose(); + if (rejectDelivery) delivery.reject(new Error('notification transport closed')); + else delivery.resolve(); + await nextEventLoopTurn(); + observer.goalChanged(null); + assert.deepEqual(goals, [active]); + assert.equal(errors.mock.callCount(), rejectDelivery ? 1 : 0); + }); +} + test('canonical Plan replacement rereads even when Goal is unchanged and rejects an old page', async () => { const reads: Array<(result: PlanQueryResult) => void> = []; const plans: number[] = []; @@ -106,6 +237,27 @@ test('failed initial Plan refresh retries without inventing a state notification observer.dispose(); }); +function goal(overrides: Partial = {}): GoalProjection { + return { + sessionId: 'session-1', + goalId: 'goal-1', + revision: 1, + condition: 'Finish the task', + status: 'active', + setAt: 1, + iterations: 0, + maxIterations: 10, + consecutiveNoProgress: 0, + blockCap: 3, + tokenBudget: null, + tokensSpent: 0, + lastReason: null, + achievedAt: null, + pausedAt: null, + ...overrides, + }; +} + function page(storeVersion: number): PlanQueryResult { return { kind: 'page', diff --git a/packages/cli/src/__tests__/acp-stdio-server.test.ts b/packages/cli/src/__tests__/acp-stdio-server.test.ts index a631b4602d..c6896a2b96 100644 --- a/packages/cli/src/__tests__/acp-stdio-server.test.ts +++ b/packages/cli/src/__tests__/acp-stdio-server.test.ts @@ -787,6 +787,104 @@ describe('Maka ACP stdio server', () => { await assert.rejects(harness.run(), (error: unknown) => error === transportError); }); + for (const method of ['_maka/goal/status', '_maka/plan/changed']) { + test(`${method} write failure closes ACP and releases the retained attachment`, { + timeout: 5_000, + }, async (t) => { + const stdin = new PassThrough(); + const transportError = new Error(`${method} transport failed`); + const errors = t.mock.method(console, 'error', () => undefined); + let sessionId: string | undefined; + let subscription: FakeSubscription | undefined; + let closes = 0; + const connection = { + request: async (operation: string, input: { sessionId: string }) => { + if (operation === 'session.create') { + sessionId = input.sessionId; + return sessionProjection({ id: sessionId }); + } + if (operation === 'connection.catalog.query') return connectionCatalogPage(); + if (operation === 'goal.query') return { sessionId, goal: null }; + if (operation === 'plan.query') + return { + kind: 'page', + sessionId, + storeVersion: 0, + latestProposalId: null, + activeExecutionId: null, + items: [], + nextCursor: null, + }; + assert.fail(`Unexpected operation ${operation}`); + }, + openSessionSubscription: async () => { + subscription = new FakeSubscription( + continuitySnapshot({ sessionId: sessionId!, projectionRevision: 1, rootTurn: null }), + Promise.resolve([]), + ); + return subscription; + }, + replaceClientCapabilities: async () => undefined, + unregisterClientCapabilities: async () => undefined, + close: async () => { + closes += 1; + }, + } as unknown as RuntimeHostConnection; + const harness = createHarness([], { + stdin, + connection, + failWrite: { method, error: transportError }, + }); + const result = assert.rejects(harness.run(), (error: unknown) => error === transportError); + const send = (id: number, method: string, params: unknown) => + stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); + try { + send(1, 'initialize', { + protocolVersion: 1, + clientCapabilities: { _meta: { '_maka/goalPlanStatus': true } }, + }); + await waitFor(() => + harness.stdoutMessages().some((message) => (message as { id?: number }).id === 1), + ); + send(2, 'session/new', { cwd: '/workspace', mcpServers: [] }); + await waitFor(() => + harness.stdoutMessages().some((message) => (message as { id?: number }).id === 2), + ); + send(3, '_maka/goal/query', { sessionId: sessionId! }); + await waitFor(() => + harness.stdoutMessages().some((message) => { + const record = message as { id?: number; method?: string }; + return record.id === 3 || record.method === method; + }), + ); + assert.ok( + harness + .stdoutMessages() + .some((message) => (message as { method?: string }).method === method), + JSON.stringify(harness.stdoutMessages()), + ); + await result; + assert.ok(subscription); + assert.equal(subscription.closeCalls, 1); + assert.equal(closes, 1); + assert.equal( + harness + .stdoutMessages() + .filter((message) => (message as { method?: string }).method === method).length, + 1, + 'the failed connection must not retry the notification', + ); + assert.ok( + errors.mock.calls.some((call) => call.arguments[1] === transportError), + 'notification failure remains diagnosable', + ); + } finally { + stdin.end(); + await result; + } + }); + } + test('serializes Session creation and configuration through the Runtime Host catalog', async () => { const lifecycle: string[] = []; let created: SessionCatalogProjection | undefined; @@ -986,6 +1084,7 @@ function createHarness( readonly stdin?: Readable; readonly connection?: RuntimeHostConnection; readonly connectError?: Error; + readonly failWrite?: { readonly method: string; readonly error: Error }; } = {}, ) { const stdin = options.stdin ?? Readable.from(chunks.map((chunk) => Buffer.from(chunk))); @@ -998,8 +1097,18 @@ function createHarness( } as unknown as RuntimeHostConnection); const stdoutChunks: Buffer[] = []; const stdout = new Writable({ + // Make the SDK await each transport write so the injected error rejects notify(). + ...(options.failWrite ? { highWaterMark: 1 } : {}), write(chunk, _encoding, callback) { stdoutChunks.push(Buffer.from(chunk)); + if ( + options.failWrite && + (JSON.parse(Buffer.from(chunk).toString('utf8')) as { method?: string }).method === + options.failWrite.method + ) { + callback(options.failWrite.error); + return; + } callback(); }, }); diff --git a/packages/cli/src/acp/session-domain-observation.ts b/packages/cli/src/acp/session-domain-observation.ts index 7617bc10b8..982af602ba 100644 --- a/packages/cli/src/acp/session-domain-observation.ts +++ b/packages/cli/src/acp/session-domain-observation.ts @@ -80,12 +80,10 @@ export class AcpSessionDomainObservation { return; this.#goalKnown = true; this.#goal = goal; - const status = { sessionId: this.#sessionId, goal }; - const key = JSON.stringify(status); - if (key !== this.#goalDelivered) { - this.#pendingGoal = status; - this.#sendGoal(); - } + // Even a return to the last delivered value must replace an older pending + // snapshot: an in-flight notification may still change what the client sees. + this.#pendingGoal = { sessionId: this.#sessionId, goal }; + this.#sendGoal(); } planChanged(): void { @@ -175,9 +173,11 @@ export class AcpSessionDomainObservation { const status = this.#pendingGoal; const epoch = this.#epoch; this.#pendingGoal = undefined; + const key = JSON.stringify(status); + if (key === this.#goalDelivered) return; this.#goalDelivery = notify(status) .then(() => { - if (!this.#disposed && epoch === this.#epoch) this.#goalDelivered = JSON.stringify(status); + if (!this.#disposed && epoch === this.#epoch) this.#goalDelivered = key; }) .catch((error: unknown) => console.error('[acp] Goal status delivery failed:', error)) .finally(() => { From eb66d15601045c78e184c841a41c819cb57d6b6e Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 11:35:29 +0800 Subject: [PATCH 3/5] fix(acp): allow live Plan replay after session restore Generated-by: Codex --- .../acp-goal-plan-child-process.test.ts | 385 +++++++++++------- packages/cli/src/acp/VALIDATION.md | 17 + packages/cli/src/acp/goal-plan-operations.ts | 5 +- packages/cli/src/acp/session-registry.ts | 17 +- 4 files changed, 275 insertions(+), 149 deletions(-) diff --git a/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts b/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts index 4f73b5e1cb..b6a07e4b1d 100644 --- a/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts +++ b/packages/cli/src/__tests__/acp-goal-plan-child-process.test.ts @@ -24,154 +24,257 @@ import { describe, test } from 'node:test'; import { methods, RequestError } from '@agentclientprotocol/sdk'; import { waitFor } from '@maka/core/test-only/async-primitives'; import { connectRuntimeHost } from '@maka/runtime-host/client'; -import { RUNTIME_HOST_PROTOCOL_VERSION } from '@maka/runtime-host/protocol'; +import { + RUNTIME_HOST_PROTOCOL_VERSION, + type PlanTurnStartResult, +} from '@maka/runtime-host/protocol'; import { withAcpChildProcessHarness } from './acp-child-process-harness.js'; describe('ACP Goal/Plan real Host routes', () => { - test('cancel stops the exact Plan Turn and close/EOF release its attachment', { - timeout: 30_000, - }, async () => { - let submitted = false; - let executionStarted!: () => void; - const started = new Promise((resolve) => { - executionStarted = resolve; - }); - const model = createServer((request, response) => { - void readRequest(request) - .then((body) => { - const input = JSON.parse(body) as { - stream?: boolean; - tools?: Array<{ function?: { name?: string } }>; - }; - if (input.stream !== true) { - response.writeHead(200, { 'content-type': 'application/json' }); - response.end( - JSON.stringify({ - id: 'summary', - object: 'chat.completion', - created: 1, - model: 'cancel-plan-fixture', - choices: [ - { - index: 0, - message: { role: 'assistant', content: 'Summary' }, - finish_reason: 'stop', - }, - ], - }), - ); - return; - } - const names = (input.tools ?? []).flatMap((tool) => - tool.function?.name ? [tool.function.name] : [], - ); - if (!submitted && names.includes('SubmitPlan')) { - submitted = true; - respondTool(response, 'SubmitPlan', { - title: 'Cancellable plan', - steps: [{ id: 'step-1', title: 'Wait', description: 'Wait for cancellation' }], - }); - } else if (!submitted) respondTool(response, 'tool_search', { query: 'SubmitPlan' }); - else { - response.writeHead(200, { 'content-type': 'text/event-stream' }); - response.write( - `data: ${JSON.stringify(modelChunk({ role: 'assistant', content: 'Working.' }, null))}\n\n`, + for (const restoreMethod of [undefined, 'load', 'resume'] as const) { + test(restoreMethod + ? `${restoreMethod} reuses an active Plan observer across retries, cancellation and close` + : 'cancel stops the exact Plan Turn and close/EOF release its attachment', { + timeout: 30_000, + }, async () => { + let submitted = false; + let executionCalls = 0; + let executionResponse: ServerResponse | undefined; + let executionStarted!: () => void; + const started = new Promise((resolve) => { + executionStarted = resolve; + }); + const model = createServer((request, response) => { + void readRequest(request) + .then((body) => { + const input = JSON.parse(body) as { + stream?: boolean; + tools?: Array<{ function?: { name?: string } }>; + }; + if (input.stream !== true) { + response.writeHead(200, { 'content-type': 'application/json' }); + response.end( + JSON.stringify({ + id: 'summary', + object: 'chat.completion', + created: 1, + model: 'cancel-plan-fixture', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Summary' }, + finish_reason: 'stop', + }, + ], + }), + ); + return; + } + const names = (input.tools ?? []).flatMap((tool) => + tool.function?.name ? [tool.function.name] : [], ); - executionStarted(); - } - }) - .catch((error: unknown) => response.destroy(error as Error)); - }); - await new Promise((resolve, reject) => { - model.once('error', reject); - model.listen(0, '127.0.0.1', resolve); - }); - const address = model.address(); - assert.ok(address && typeof address !== 'string'); - try { - await withAcpChildProcessHarness( - async (harness) => { - const statuses: unknown[] = []; - await harness.withClient( - async ({ context }) => { - await context.request(methods.agent.initialize, { - protocolVersion: 1, - clientCapabilities: { _meta: { '_maka/turnStatus': true } }, - }); - const { sessionId } = await context.request(methods.agent.session.new, { - cwd: harness.workspaceRoot, - mcpServers: [], - }); - await context.request(methods.agent.session.setConfigOption, { - sessionId, - configId: 'collaboration_mode', - value: 'plan', - }); - await context.request(methods.agent.session.prompt, { - sessionId, - prompt: [{ type: 'text', text: 'Prepare a cancellable plan' }], + if (!submitted && names.includes('SubmitPlan')) { + submitted = true; + respondTool(response, 'SubmitPlan', { + title: 'Cancellable plan', + steps: [{ id: 'step-1', title: 'Wait', description: 'Wait for cancellation' }], }); - const page = (await context.request('_maka/plan/query', { - kind: 'list_start', - sessionId, - })) as { - storeVersion: number; - items: Array<{ kind: string; proposal?: { proposalId: string; revision: number } }>; - }; - const proposal = page.items.find((item) => item.kind === 'proposal')?.proposal; - assert.ok(proposal); - await context.request('_maka/plan/turn/start', { - kind: 'approve_proposal', - sessionId, - proposalId: proposal.proposalId, - expectedRevision: proposal.revision, - expectedStoreVersion: page.storeVersion, - turnId: randomUUID(), - }); - await started; - await context.notify(methods.agent.session.cancel, { sessionId }); - await waitFor(() => statuses.length > 0); - const current = (await context.request('_maka/plan/query', { - kind: 'list_start', - sessionId, - })) as { - items: Array<{ kind: string; execution?: { status: string; executionId: string } }>; - }; - const execution = current.items.find((item) => item.kind === 'execution')?.execution; - assert.equal(execution?.status, 'interrupted'); - await context.request('_maka/plan/control', { - kind: 'cancel_execution', - sessionId, - executionId: execution.executionId, - operationId: randomUUID(), - }); - await context.request(methods.agent.session.close, { sessionId }); + } else if (!submitted) respondTool(response, 'tool_search', { query: 'SubmitPlan' }); + else { + executionCalls += 1; + executionResponse = response; + response.writeHead(200, { 'content-type': 'text/event-stream' }); + response.write( + `data: ${JSON.stringify(modelChunk({ role: 'assistant', content: 'Working.' }, null))}\n\n`, + ); + executionStarted(); + } + }) + .catch((error: unknown) => response.destroy(error as Error)); + }); + await new Promise((resolve, reject) => { + model.once('error', reject); + model.listen(0, '127.0.0.1', resolve); + }); + const address = model.address(); + assert.ok(address && typeof address !== 'string'); + try { + await withAcpChildProcessHarness( + async (harness) => { + const statuses: unknown[] = []; + await harness.withClient( + async ({ context }) => { + await context.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { _meta: { '_maka/turnStatus': true } }, + }); + const { sessionId } = await context.request(methods.agent.session.new, { + cwd: harness.workspaceRoot, + mcpServers: [], + }); + await context.request(methods.agent.session.setConfigOption, { + sessionId, + configId: 'collaboration_mode', + value: 'plan', + }); + await context.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Prepare a cancellable plan' }], + }); + const page = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + storeVersion: number; + items: Array<{ + kind: string; + proposal?: { proposalId: string; revision: number }; + }>; + }; + const proposal = page.items.find((item) => item.kind === 'proposal')?.proposal; + assert.ok(proposal); + const approvalInput = { + kind: 'approve_proposal' as const, + sessionId, + proposalId: proposal.proposalId, + expectedRevision: proposal.revision, + expectedStoreVersion: page.storeVersion, + turnId: randomUUID(), + }; + const admission = (await context.request( + '_maka/plan/turn/start', + approvalInput, + )) as PlanTurnStartResult; + await started; + if (restoreMethod) { + const sibling = await harness.spawnSibling(); + const restoredStatuses: unknown[] = []; + const restoredText: string[] = []; + try { + await sibling.withClient( + async ({ context: restored }) => { + await restored.request(methods.agent.initialize, { + protocolVersion: 1, + clientCapabilities: { _meta: { '_maka/turnStatus': true } }, + }); + await restored.request(methods.agent.session[restoreMethod], { + sessionId, + cwd: harness.workspaceRoot, + mcpServers: [], + }); + for (let retry = 0; retry < 2; retry += 1) { + const replay = (await restored.request( + '_maka/plan/turn/start', + approvalInput, + )) as PlanTurnStartResult; + assert.equal(replay.turn.turnId, admission.turn.turnId); + assert.equal(replay.turn.runId, admission.turn.runId); + assert.equal(replay.turn.status, 'running'); + assert.equal(replay.plan.executionId, admission.plan.executionId); + } + // A rejected replay must not dispose the retained output consumer. + await assert.rejects( + restored.request('_maka/plan/turn/start', { + ...approvalInput, + expectedRevision: proposal.revision + 1, + }), + (error: unknown) => + error instanceof RequestError && + (error.data as { code?: string }).code === 'operation_conflict', + ); + assert.ok(executionResponse); + executionResponse.write( + `data: ${JSON.stringify(modelChunk({ content: 'After restored replay.' }, null))}\n\n`, + ); + await waitFor(() => + restoredText.join('').includes('After restored replay.'), + ); + await restored.notify(methods.agent.session.cancel, { sessionId }); + await waitFor(() => restoredStatuses.length > 0); + assert.equal( + (restoredStatuses[0] as { turnId: string }).turnId, + admission.turn.turnId, + ); + await restored.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app + .onNotification(methods.client.session.update, ({ params }) => { + if ( + params.update.sessionUpdate === 'agent_message_chunk' && + params.update.content.type === 'text' + ) + restoredText.push(params.update.content.text); + }) + .onNotification( + '_maka/turn/status', + { parse: (value: unknown) => value }, + ({ params }) => { + restoredStatuses.push(params); + }, + ), + ); + await sibling.closeStdin(); + assert.deepEqual(await sibling.waitForExit(), { code: 0, signal: null }); + assert.equal( + restoredText.join('').split('After restored replay.').length - 1, + 1, + ); + assert.equal(restoredStatuses.length, 1, 'one terminal notification per Turn'); + assert.equal(executionCalls, 1, 'retries must not invoke the model again'); + } finally { + await sibling.close(); + } + } + await context.notify(methods.agent.session.cancel, { sessionId }); + await waitFor(() => statuses.length > 0); + const current = (await context.request('_maka/plan/query', { + kind: 'list_start', + sessionId, + })) as { + items: Array<{ + kind: string; + execution?: { status: string; executionId: string }; + }>; + }; + const execution = current.items.find( + (item) => item.kind === 'execution', + )?.execution; + assert.equal(execution?.status, 'interrupted'); + await context.request('_maka/plan/control', { + kind: 'cancel_execution', + sessionId, + executionId: execution.executionId, + operationId: randomUUID(), + }); + await context.request(methods.agent.session.close, { sessionId }); + }, + (app) => + app.onNotification( + '_maka/turn/status', + { parse: (value: unknown) => value }, + ({ params }) => { + statuses.push(params); + }, + ), + ); + await harness.closeStdin(); + assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); + }, + { + startRuntimeHost: true, + model: { + id: 'cancel-plan-fixture', + thinkingLevels: [], + baseUrl: `http://127.0.0.1:${address.port}/v1`, }, - (app) => - app.onNotification( - '_maka/turn/status', - { parse: (value: unknown) => value }, - ({ params }) => { - statuses.push(params); - }, - ), - ); - await harness.closeStdin(); - assert.deepEqual(await harness.waitForExit(), { code: 0, signal: null }); - }, - { - startRuntimeHost: true, - model: { - id: 'cancel-plan-fixture', - thinkingLevels: [], - baseUrl: `http://127.0.0.1:${address.port}/v1`, }, - }, - ); - } finally { - await new Promise((resolve) => model.close(() => resolve())); - } - }); + ); + } finally { + await new Promise((resolve) => model.close(() => resolve())); + } + }); + } test('Plan paging preserves storeVersion and returns revision_changed after an external write', { timeout: 45_000, diff --git a/packages/cli/src/acp/VALIDATION.md b/packages/cli/src/acp/VALIDATION.md index f913fe9f1b..239e0bd6b5 100644 --- a/packages/cli/src/acp/VALIDATION.md +++ b/packages/cli/src/acp/VALIDATION.md @@ -19,6 +19,23 @@ # ACP validation record +## PR8 restored Plan replay fix — September 26, 2026 + +A fresh ACP process can now replay `plan.turn.start` for a live Turn after +`session/load` or `session/resume`. The existing output observer stays attached; +the Host validates the original Turn identity and returns its admission result. +The replay does not take ownership of the restored observer's lifecycle. + +Both real SDK/stdio/Host regression cases failed with `registry_closed` before +the fix and pass after rebuilding. They cover repeated admission replies with +unchanged Turn/Run/execution identities, Host rejection of a conflicting replay, +continued output exactly once, one model execution, cancellation, one terminal +notification, Session close, and clean EOF. + +Validation: root build and workspace typecheck passed; full CLI `test:dist` +passed with 1277 passed, 3 skipped, 0 failed. Root lint, format checking, and +Desktop/UI knip passed. No Host implementation or wire schema changed. + ## PR8 Goal/Plan execution — September 26, 2026 Implementation baseline: official `apache/maka` main at diff --git a/packages/cli/src/acp/goal-plan-operations.ts b/packages/cli/src/acp/goal-plan-operations.ts index 4ba1917ed9..a64d972fe2 100644 --- a/packages/cli/src/acp/goal-plan-operations.ts +++ b/packages/cli/src/acp/goal-plan-operations.ts @@ -42,7 +42,8 @@ import type { AcpAdmittedTurnObservation } from './turn-observation.js'; export interface PreparedGoalPlanOperation { readonly connection: AcpSessionRegistryConnection; readonly observation?: AcpAdmittedTurnObservation; - readonly terminalReplay?: boolean; + /** Host already exposed this Turn; a replay does not own its observation. */ + readonly observedReplay?: boolean; reconcileAdmission?(): void; cancelObservation?(): void; commit(): void; @@ -108,7 +109,7 @@ export class AcpGoalPlanOperations { context.signal.throwIfAborted(); this.#port.assertCurrent(input.sessionId); const observation = prepared.observation; - if (!observation && !prepared.terminalReplay) + if (!observation && !prepared.observedReplay) throw new Error('Plan Turn observer was not installed'); observation?.markDispatched(); dispatched = true; diff --git a/packages/cli/src/acp/session-registry.ts b/packages/cli/src/acp/session-registry.ts index 354c641370..51aa8c07a3 100644 --- a/packages/cli/src/acp/session-registry.ts +++ b/packages/cli/src/acp/session-registry.ts @@ -303,7 +303,7 @@ export class AcpSessionRegistry { ?.setClient(context.interactions ?? UNAVAILABLE_INTERACTION_CLIENT); const creatingAttachment = !this.#attachments.has(sessionId); let observation: AcpAdmittedTurnObservation | undefined; - let terminalReplay = false; + let observedReplay = false; let admissionFinished = false; let attachment: RuntimeHostSessionChannel | undefined; const finishAdmission = (retain: boolean, error?: unknown) => { @@ -348,7 +348,7 @@ export class AcpSessionRegistry { ); attachment = prepared.attachment; observation = prepared.observation; - terminalReplay = prepared.terminalReplay ?? false; + observedReplay = prepared.observedReplay ?? false; if (observation) { const state = this.#pendingPlanAdmissions.get(observation) ?? { users: 0, @@ -364,7 +364,7 @@ export class AcpSessionRegistry { } return { connection, - ...(terminalReplay ? { terminalReplay } : {}), + ...(observedReplay ? { observedReplay } : {}), ...(observation ? { observation } : {}), ...(observation ? { reconcileAdmission: () => this.#queryPromptAdmission(observation!, connection) } @@ -402,7 +402,7 @@ export class AcpSessionRegistry { ): Promise<{ attachment: RuntimeHostSessionChannel; observation?: AcpAdmittedTurnObservation; - terminalReplay?: boolean; + observedReplay?: boolean; }> { await this.#mcps.get(sessionId)?.ready(context.signal); const attachment = await this.#ensureAttachment(sessionId, connection, context); @@ -412,9 +412,14 @@ export class AcpSessionRegistry { if (!turnId) return { attachment }; const root = attachment.snapshot.rootTurn; if (root?.turnId === turnId && isRuntimeHostTerminalTurn(root)) - return { attachment, terminalReplay: true }; + return { attachment, observedReplay: true }; const observation = await this.#adoptTurn(sessionId, turnId, attachment, true); - if (!(observation instanceof AcpAdmittedTurnObservation)) throw registryClosedError(operation); + if (!observation) throw registryClosedError(operation); + // Load/resume already observes an admitted Host Turn without local admission + // bookkeeping. Let the Host validate the replay while preserving that sole + // consumer, including when this request is rejected or its result is lost. + if (!(observation instanceof AcpAdmittedTurnObservation)) + return { attachment, observedReplay: true }; return { attachment, observation }; } From 23623a7226d403caa39257b2955dc082b587c727 Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:18:44 +0800 Subject: [PATCH 4/5] docs(acp): clarify domain notification delivery failures Generated-by: Codex --- docs/architecture/acp-pr8-goal-plan-design.zh-CN.md | 5 ++++- packages/cli/src/acp/README.md | 6 ++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md b/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md index a890916cd3..4227344d70 100644 --- a/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md +++ b/docs/architecture/acp-pr8-goal-plan-design.zh-CN.md @@ -119,6 +119,8 @@ Goal/Plan 成功响应表示 Host 已接受该操作,不表示任务成功完 域通知是最新权威状态,不是每次转换的审计日志。允许合并中间刷新;同一 attachment 内不得被迟到旧结果回退。Goal 以 goalId/revision 去重,Plan 以 storeVersion 去重;重连或 canonical replacement 必须失效旧请求并重新读取,不跨 Host epoch 盲比版本数。 +Goal 与 Plan 域通知均为尽力投递:发送失败只记录 stderr,不自动重试;若没有后续域更新或 canonical replacement,客户端可能保留旧视图,应通过 `_maka/goal/query` 或 `_maka/plan/query` 恢复权威状态。有界退避只用于刷新 Plan 提示时的 Host 查询失败,不用于通知发送失败。 + 不保证响应先于通知:Host 可在请求尚未返回时产生输出或域变化,客户端必须先注册 handler,再按 Session、实体 ID 和版本关联。只有 Turn 终态通知需要严格等待该 Turn 的文本/工具输出 flush;域通知不充当输出完成屏障。 ### 4.3 错误与未知结果 @@ -133,7 +135,8 @@ Goal/Plan 成功响应表示 Host 已接受该操作,不表示任务成功完 | Plan start 丢响应 | 可复用 PR6 有界 `turn.query` 核实精确 turnId;只有完整且可关联的 plan 与 turn 结果都可建立才返回正常成功,否则保留 outcome_unknown,不伪造 PlanControlResult | | Goal arm 丢响应 | query 可展示当前事实,但相同 condition/budget 不证明原请求成功;保留不确定性,不能据此自动重新 arm | | Plan control persistence_failed | 保留 Host code 及未知结果含义;query 恢复可见状态,不自动变更 | -| 状态刷新失败 | 不撤销已确认 mutation,不虚报业务终态;保留 dirty,限定重试,日志写 stderr;显式 query 仍能返回可诊断错误 | +| Plan 状态刷新中的 Host 查询失败 | 不撤销已确认 mutation,不虚报业务终态;保留 dirty,限定重试,日志写 stderr;显式 query 仍能返回可诊断错误 | +| Goal/Plan 域通知发送失败 | 日志写 stderr,不自动重试发送,也不撤销已确认 mutation;客户端通过显式 query 恢复权威状态 | 未确认是否生效的 mutation 必须保留已准备的合法观察/交互资源,直到权威事实、close 或 dispose 决定其生命周期。请求 signal 的结束不等于 retained attachment 的结束。 diff --git a/packages/cli/src/acp/README.md b/packages/cli/src/acp/README.md index c39a0c1b0a..eaf6aa1872 100644 --- a/packages/cli/src/acp/README.md +++ b/packages/cli/src/acp/README.md @@ -113,6 +113,12 @@ for the full proposal, execution, and step projection. A client without the notification preference can still use every request. Register notification handlers before sending mutations: a Host change can precede its request result. +Both domain notifications use best-effort delivery: a rejected send is logged to +stderr and is not automatically retried. Without a later domain update or +canonical replacement, the client may keep an older view; use `_maka/goal/query` +or `_maka/plan/query` to recover authoritative state. The bounded backoff applies +only to failed Host reads while refreshing Plan hints, not notification sends. + Successful mutation responses mean Host admission, not task completion. A lost dispatched response returns `error.data.code: outcome_unknown` with the original Session and available entity/operation/Turn identity. The adapter never resends From 1f5b0085f396141f55e17b1018b6fbdd4a19331c Mon Sep 17 00:00:00 2001 From: sungl <81428141+Sun-GLiang@users.noreply.github.com> Date: Sat, 26 Sep 2026 14:22:38 +0800 Subject: [PATCH 5/5] test(acp): cover live domain notification failures Generated-by: Codex --- .../acp-session-domain-observation.test.ts | 61 ++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/__tests__/acp-session-domain-observation.test.ts b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts index 41717f96f3..138a8ff4fb 100644 --- a/packages/cli/src/__tests__/acp-session-domain-observation.test.ts +++ b/packages/cli/src/__tests__/acp-session-domain-observation.test.ts @@ -18,7 +18,7 @@ */ import assert from 'node:assert/strict'; -import { setImmediate as nextEventLoopTurn } from 'node:timers/promises'; +import { setImmediate as nextEventLoopTurn, setTimeout as delay } from 'node:timers/promises'; import { test } from 'node:test'; import { deferred, waitFor } from '@maka/core/test-only/async-primitives'; import type { GoalProjection, PlanQueryResult } from '@maka/runtime-host/protocol'; @@ -154,6 +154,65 @@ for (const rejectDelivery of [false, true]) { }); } +test('failed Goal delivery is logged once and retried only after another domain update', async (t) => { + const active = goal(); + const goals: Array = []; + const errors = t.mock.method(console, 'error', () => undefined); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(0), + goalNotify: () => async (status) => { + goals.push(status.goal); + if (goals.length === 1) throw new Error('temporary notification failure'); + }, + planNotify: () => undefined, + }); + t.after(() => observer.dispose()); + + observer.goalChanged(active); + await waitFor(() => errors.mock.callCount() === 1); + await delay(100); + assert.deepEqual(goals, [active], 'a failed send is not automatically retried'); + assert.match(String(errors.mock.calls[0]?.arguments[0]), /Goal status delivery failed/); + + observer.goalChanged({ ...active }); + await waitFor(() => goals.length === 2); + assert.deepEqual(goals, [active, active], 'failure did not mark the Goal as delivered'); + assert.equal(errors.mock.callCount(), 1); + observer.goalChanged({ ...active }); + await nextEventLoopTurn(); + assert.equal(goals.length, 2, 'a successfully delivered duplicate is suppressed'); +}); + +test('failed Plan delivery is logged once and retried only after another domain update', async (t) => { + const plans: number[] = []; + const errors = t.mock.method(console, 'error', () => undefined); + const observer = new AcpSessionDomainObservation({ + sessionId: 'session-1', + queryPlan: async () => page(4), + goalNotify: () => undefined, + planNotify: () => async (status) => { + plans.push(status.storeVersion); + if (plans.length === 1) throw new Error('temporary notification failure'); + }, + }); + t.after(() => observer.dispose()); + + observer.planChanged(); + await waitFor(() => errors.mock.callCount() === 1); + await delay(100); + assert.deepEqual(plans, [4], 'a failed send is not automatically retried'); + assert.match(String(errors.mock.calls[0]?.arguments[0]), /Plan status delivery failed/); + + observer.planChanged(); + await waitFor(() => plans.length === 2); + assert.deepEqual(plans, [4, 4], 'failure did not mark the Plan as delivered'); + assert.equal(errors.mock.callCount(), 1); + observer.planChanged(); + await nextEventLoopTurn(); + assert.equal(plans.length, 2, 'a successfully delivered duplicate is suppressed'); +}); + test('canonical Plan replacement rereads even when Goal is unchanged and rejects an old page', async () => { const reads: Array<(result: PlanQueryResult) => void> = []; const plans: number[] = [];