From eb8cecb8e171ae6075ef13021e4d26b936dea0da Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 21 Jul 2026 15:01:43 +0800 Subject: [PATCH 01/17] refactor(agent-core-v2): extract toolApproval domain from permissionGate - add agent-scoped `toolApproval` domain owning the approval round-trip: builds approval requests, drives the session/approval broker, publishes permission.approval.* events, records session approval rules, and resolves ask continuations - slim permissionPolicy down to the static risk-adjudication chain; drop the dynamic `registerPolicy` mechanism - move harness constraints out of the policy chain into their owning domains as toolExecutor hooks ordered before 'permission': plan-mode guard (plan), swarm batch exclusivity and AgentSwarm approve (swarm), goal-start review (goal), exit-plan review (plan/exitPlanModeReview), and btw deny (session/btw) - delete the now-unused policies (deny-all, plan-mode-guard-deny, plan-mode-tool-approve, goal-start-review-ask, swarm-mode-agent-swarm- approve, agent-swarm-exclusive-deny) - update Permission.md, AGENTS.md, and check-domain-layers for the new domain layout --- AGENTS.md | 2 +- packages/agent-core-v2/docs/Permission.md | 54 +- .../scripts/check-domain-layers.mjs | 14 +- .../src/agent/goal/goalService.ts | 83 ++- .../permissionGate/permissionGateService.ts | 248 +------ .../permissionPolicy/permissionPolicy.ts | 4 +- .../permissionPolicyService.ts | 42 +- .../policies/agent-swarm-exclusive-deny.ts | 47 -- .../policies/default-tool-approve.ts | 4 + .../permissionPolicy/policies/deny-all.ts | 16 - .../policies/goal-start-review-ask.ts | 37 -- .../permissionPolicy/policies/path-utils.ts | 9 - .../policies/plan-mode-guard-deny.ts | 57 -- .../policies/plan-mode-tool-approve.ts | 39 -- .../swarm-mode-agent-swarm-approve.ts | 18 - .../exitPlanModeReview.ts} | 89 +-- .../src/agent/plan/planService.ts | 126 +++- .../src/agent/swarm/swarmService.ts | 53 +- .../src/agent/toolApproval/toolApproval.ts | 48 ++ .../agent/toolApproval/toolApprovalService.ts | 266 ++++++++ .../src/agent/toolExecutor/toolHooks.ts | 9 +- packages/agent-core-v2/src/index.ts | 3 +- .../src/session/btw/btwService.ts | 20 +- .../test/agent/goal/goal.test.ts | 142 +++- .../goal/injection/goalInjection.test.ts | 10 +- .../agent-core-v2/test/agent/goal/stubs.ts | 25 + .../test/agent/goal/tools/goal-tools.test.ts | 3 + .../permissionGate/permissionGate.test.ts | 581 +++------------- .../permissionPolicyService.test.ts | 414 +----------- .../policies/default-tool-approve.test.ts | 19 +- .../exit-plan-mode-review-ask.test.ts | 331 ---------- .../policies/goal-start-review-ask.test.ts | 108 --- .../policies/plan-mode-guard-deny.test.ts | 234 ------- .../test/agent/permissionPolicy/stubs.ts | 1 - .../test/agent/plan/plan.test.ts | 13 +- .../test/agent/plan/planGuard.test.ts | 569 ++++++++++++++++ .../test/agent/swarm/swarm.test.ts | 98 ++- .../agent/toolApproval/toolApproval.test.ts | 621 ++++++++++++++++++ .../test/session/btw/btw.test.ts | 62 +- 39 files changed, 2344 insertions(+), 2175 deletions(-) delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/agent-swarm-exclusive-deny.ts delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/goal-start-review-ask.ts delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-guard-deny.ts delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-tool-approve.ts delete mode 100644 packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts rename packages/agent-core-v2/src/agent/{permissionPolicy/policies/exit-plan-mode-review-ask.ts => plan/exitPlanModeReview.ts} (69%) create mode 100644 packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts create mode 100644 packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts create mode 100644 packages/agent-core-v2/test/agent/goal/stubs.ts delete mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts delete mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts delete mode 100644 packages/agent-core-v2/test/agent/permissionPolicy/policies/plan-mode-guard-deny.test.ts create mode 100644 packages/agent-core-v2/test/agent/plan/planGuard.test.ts create mode 100644 packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts diff --git a/AGENTS.md b/AGENTS.md index 2349080c17..a2b97d7f2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace and the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards, "load earlier" pages with `before_turn`), while `/api/v1/ws` is a delta-only channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored). Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards, "load earlier" pages with `before_turn`), while `/api/v1/ws` is a delta-only channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored). Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. diff --git a/packages/agent-core-v2/docs/Permission.md b/packages/agent-core-v2/docs/Permission.md index 6cd420d6a7..941371a9a5 100644 --- a/packages/agent-core-v2/docs/Permission.md +++ b/packages/agent-core-v2/docs/Permission.md @@ -4,6 +4,8 @@ > **权限系统应是一个「可组合、可注册的责任链(微内核)」**:内核只负责按顺序跑链、首个命中赢;具体权限维度(policy)由各自的 Domain Service 通过注册表插入;工具只需在 `resolveExecution` 里声明标准化的资源访问(`accesses`),通用维度集中消费这份元数据。 > +> **链只裁决危险程度**。policy 节点回答的是「这个调用有多危险、用户能否逐次豁免这个判断」——它产出的 `ask`/`deny` 永远可被用户豁免。**Harness 约束不是权限**:运行机制为自身正确性施加的限制(plan 模式禁写、AgentSwarm 批量排他、btw side-question fork 禁工具、goal 预算拒绝)产出的是无 ask 通道、用户无法逐次豁免的硬 deny,它们以 `before: 'permission'` 的 executor hook 挂在各自 domain(先例:`goalService.ts` 的 `goal-budget-reject`)。产物审批(plan review、goal-start review)同样不是权限:由 owning domain 拦截自己的工具、直接驱动共享的 `IAgentToolApprovalService` 审批往返。 +> > **不引入 Casbin**——因为这里「难的是决策行为」(续体、副作用、RPC、状态机),不是「匹配 + 标量决策」。 --- @@ -154,7 +156,7 @@ priority 的痛点是「多模块各自贡献规则时数字撞车」。agent-co 1. **链编码「权限维度」,不编码「工具」**。新增工具不延长链;只有新增维度才加节点。 2. **两条贡献路径**:高频琐碎的具体内容走**数据路径**(规则);低频有行为的新维度走**代码路径**(policy)。 -3. **Domain 自注册**:拥有专属维度的 domain(plan/goal/swarm)在 DI 中自注册 policy,镜像 v2 已有的「domain 自注册工具」。 +3. **guard/review 下链,风险上链**:Harness 约束与产物审批以 executor hook 挂在 owning domain(见 5.4);domain 贡献的**风险**维度才在 DI 中自注册 policy,镜像 v2 已有的「domain 自注册工具」。 4. **工具声明资源,通用维度消费**:bash/write/read 等只声明 `accesses`,文件/安全维度集中判断。 ### 5.2 核心抽象 @@ -204,23 +206,23 @@ this.policies = registry.list() 绝大部分增长走数据路径——节点数被「行为种类」约束,规则数才随具体情况增长(规则匹配是廉价的 Set/glob)。 -### 5.4 Domain 自注册 +### 5.4 Domain 维度:guard/review 走 executor hook,风险维度走链注册 -镜像 v2 里「domain 在构造函数中 `toolRegistry.register(...)`」的现成做法。PlanService 自注册其维度: +**Harness 约束与产物审批不再上链。** 拥有它们的 domain 注册一个 `before: 'permission'` 的 executor hook,自行裁决: ```ts -// src/plan/planService.ts -constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) { - registry.register({ name: 'plan-mode-guard-deny', phase: 'guard', - factory: a => new PlanModeGuardDenyPolicy(a.get(IAgentPlanService)) }); - registry.register({ name: 'plan-mode-tool-approve', phase: 'mode', - factory: a => new PlanModeToolApprovePolicy(a.get(IAgentPlanService)) }); - registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask', - factory: a => new ExitPlanModeReviewAskPolicy(a.get(IAgentPlanService), a.get(IAgentPermissionModeService)) }); +// src/plan/planService.ts —— 构造函数 +constructor(@IAgentPermissionGate _gate, @IAgentToolExecutorService executor, ...) { + executor.hooks.onBeforeExecuteTool.register('plan-guard', handler, { before: 'permission' }); } ``` -复杂 domain 可对外只注册**一个复合节点**(Composite),内部跑小链,避免泄漏内部顺序到全局。 +- 注入用不到的 `IAgentPermissionGate`(`_` 前缀)是为了强制 gate 先构造——内置工具注册器可能比 gate 更早构造本 domain 服务,否则 `before: 'permission'` 找不到目标。 +- **guard(硬 deny)**:`ctx.decision = { block: true, reason: toolApproval.formatDenyMessage(...) }`,不调 `next()`。deny 短路在链前是对的——这些条目本来就挂在链首。 +- **review(产物审批)**:拦截自家工具,自己驱动 `IAgentToolApprovalService.requestToolApproval(ctx, ask, origin)` 并把续体折进 `ctx.decision`;不审批的情形一律 `next()`,让用户规则继续生效。 +- **纯放行**:hook 里不要短路 approve——把工具加进 `default-tool-approve` 白名单并 `next()`,保住用户 deny/ask 规则的优先权。 + +**domain 贡献的风险维度仍走链**(下面的注册表路径):domain 状态会改变*危险度*结论的,经 `IPermissionPolicyRegistry` 自注册 policy,镜像 v2 里「domain 在构造函数中 `toolRegistry.register(...)`」的现成做法。复杂 domain 可对外只注册**一个复合节点**(Composite),内部跑小链,避免泄漏内部顺序到全局。 ### 5.5 工具运行时声明资源(`resolveExecution` / `accesses`) @@ -269,21 +271,25 @@ type ToolResourceAccess = ### 5.6 维度归属 -| 维度 | 拥有者(谁注册) | 类型 | +| 维度 | 拥有者 | 类型 | |---|---|---| | 外部钩子否决 | `externalHooks` domain | 通用 | -| 工具批量排他 | `swarm` domain | domain 专属(跟 AgentSwarm 工具一起走) | -| 运行模式姿态 | `permissionMode` domain | 通用 | -| Plan 模式约束 | `plan` domain | domain 专属 | -| Goal 启动审批 | `goal` domain | domain 专属 | +| 工具批量排他 | `swarm` domain —— executor hook `swarm-exclusive` | Harness 约束(链外) | +| Plan 写守卫 | `plan` domain —— executor hook `plan-guard` | Harness 约束(链外) | +| Plan 审批 | `plan` domain —— 同 hook + `toolApproval` | 产物审批(链外) | +| Goal 启动审批 | `goal` domain —— executor hook `goal-start-review` + `toolApproval` | 产物审批(链外) | +| Goal 预算拒绝 | `goal` domain —— executor hook `goal-budget-reject` | Harness 约束(链外) | +| btw 禁工具 | `btw` domain —— fork 上的 executor hook `btw-deny-all` | Harness 约束(链外) | +| 运行模式姿态(auto/yolo) | `permissionMode` domain(链节点,待「档位 × 路由」拆分) | 通用 | | 静态配置规则 | `permissionRules` domain | 通用(数据路径) | | 会话批准记忆 | `permissionRules` domain | 通用 | | 敏感/特殊路径 | 通用「文件访问/安全」维度 | 通用(消费 `accesses`) | -| 工具内在风险 | 核心 permission | 通用(消费工具声明) | +| 工具内在风险 | 核心 permission(`default-tool-approve`) | 通用(消费工具声明) | | 工作区写信任 | 通用「文件访问/安全」维度 | 通用(消费 `accesses`) | | 兜底 | 核心 permission | 通用 | +| 审批往返 | `toolApproval` domain —— 供 gate 的 ask 与各域 review 共用 | 基础设施 | -规律:**专属维度跟着拥有它的 domain + 工具一起走;通用维度集中注册,靠工具声明的 `accesses` 跨工具生效。** +规律:**Harness 约束与产物审批跟着 owning domain 走 executor hook;风险维度以 policy 上链(注册表落地后自注册);通用维度集中注册,靠工具声明的 `accesses` 跨工具生效。** --- @@ -295,7 +301,7 @@ type ToolResourceAccess = | mode 处理 | policy 内部 `if (mode !== 'x') return` | 声明式 `modes` 元数据,compose 时过滤 | | 按 agent 区分 | 散落 `agent.type === 'sub'` | 声明式 `agentTypes` 元数据 | | 外部扩展 | 仅 `PreToolUse` hook 一个固定槽 | 注册表开放注册 policy(代码)+ rule(数据) | -| Domain 维度 | 集中在核心文件 | plan/goal/swarm 各自 domain 自注册 | +| Domain 维度 | 集中在核心文件 | guard/review 走 domain 自带 executor hook;风险维度走 domain 自注册 policy | | 工具维度 | 工具声明 `accesses`,维度集中 | 不变,扩展 `ToolResourceAccess` 资源类型 | | 决策行为 | 续体 + 副作用(已具备) | 不变(这是必须保留的核心能力) | | 运行时性能 | 顺序链 + 短路 | 不变;节点增多时可加工具名索引优化 | @@ -310,9 +316,9 @@ type ToolResourceAccess = 渐进式,避免一步到位: -1. **第一步:注册表 + Composer(行为零变化)**。把 v2 `PermissionPolicyService` 构造函数里硬编码的 19 个 `new`,改为从 `IPermissionPolicyRegistry` 读取并组装;现有 policy 原样注册。立刻获得多 agent/mode 可选链与外部注册入口。 -2. **第二步:声明式 modes**。把 `YoloModeApprove` / `AutoModeApprove` 里的 mode 守门提到 `modes` 元数据。 -3. **第三步:Domain 维度下沉**。把 plan/goal/swarm 相关 policy 的注册移到各自 domain service 构造函数。 +1. ~~**Domain 维度下沉**~~(已完成)。plan guard/review、goal-start review、swarm 批量排他、btw deny-all 已从链上移出,以 `before: 'permission'` 的 executor hook 挂在各自 domain;审批往返提取为共享的 `IAgentToolApprovalService`;`registerPolicy` 机制删除(btw 是唯一生产用例)。链上只剩 12 个危险度判定节点。 +2. **档位 × 路由拆分**。把「危险度档位」(只读/读写/yolo——`yolo-mode-approve` 的实质)与「交互路由」(`auto-mode-approve` / `auto-mode-ask-user-question-deny` 的实质:不经用户地路由 ask 与 review)拆开;路由层落在 `session/approval` broker 上,剩余 3 个 mode policy 在此步离开链。 +3. **注册表 + Composer(行为零变化)**。把 `PermissionPolicyService` 构造函数里硬编码的 `new`,改为从 `IPermissionPolicyRegistry` 读取并组装;mode 守门提升为 `modes` 元数据。获得多 agent/mode 可选链与外部注册入口。 4. **第四步(按需):扩展资源类型**。当非文件资源(网络/DB/shell)需要结构化维度时,扩展 `ToolResourceAccess` 联合。 5. **第五步(按需):匹配内核换 Casbin**。仅当外部规则真的需要 RBAC/ABAC 语义时,把数据路径的规则匹配内核换成 Casbin。不到此步不引。 @@ -324,4 +330,4 @@ type ToolResourceAccess = 2. **同 phase 多节点的排序**:注册顺序是否足够,还是需要显式 `order` 逃生舱? 3. **`ToolResourceAccess` 扩展节奏**:哪些非文件资源优先纳入(shell / network / datastore)? 4. **v1 → v2 迁移时机**:v2 权限子系统目前是 v1 类型/逻辑的薄包装,何时把 `accesses`、`PermissionPolicyResult` 等提升为正式 v2 类型? -5. **运行时性能阈值**:节点数达到多少时引入工具名索引(`byTool` 分派)优化?当前 19 个节点、首个命中短路,远未触及。 +5. **运行时性能阈值**:节点数达到多少时引入工具名索引(`byTool` 分派)优化?当前 12 个节点、首个命中短路,远未触及。 diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index d02848869c..828b301ee5 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -137,6 +137,7 @@ const DOMAIN_LAYER = new Map([ ['sessionAgentProfileCatalog', 3], ['sessionToolPolicy', 3], ['permissionGate', 3], + ['toolApproval', 3], ['flag', 3], ['toolExecutor', 3], ['toolResultTruncation', 3], @@ -372,13 +373,10 @@ function domainFromRel(rel, { exemptRootFile }) { * Store to its filesystem backend (same role as * the storage backend bindings). * - * - `permissionGate>approval` : permissionGate(Agent) requests approval(Session broker). + * - `toolApproval>approval` : toolApproval(Agent) requests approval(Session broker) + * for permissionGate asks and plan/goal reviews. * - `userTool>interaction` : userTool(Agent) requests host-side execution * through the Session interaction broker. - * - `permissionPolicy>plan` : plan-mode approval policies need the current - * Agent plan state to approve/deny tool use. - * - `permissionPolicy>swarm` : swarm-mode approval policy needs the current - * Agent swarm state to approve AgentSwarm. * - `skill>loop` : skill activate starts a turn through the loop (same Agent scope intent). * - `swarm>agentLifecycle`: swarm spawns/manages sub-agents. * - `cron>agentLifecycle` : cron coordinator steers the main agent. @@ -405,15 +403,15 @@ const ALLOWED_EXCEPTIONS = new Set([ // auth-independent `web` domain. 'auth>tool', 'auth>toolRegistry', - 'permissionGate>approval', + // `toolApproval` (Agent, L3) owns the approval round-trip for permissionGate + // asks and plan/goal reviews, driven through the Session approval broker. + 'toolApproval>approval', // `permissionRules` (L3) persists the approval broker's `ApprovalResponse` // (Session, L7) verbatim in its wire-logged `PermissionApprovalResultRecord` // — a real cross-scope dependency, surfaced here rather than hidden behind a // re-declared copy of the shape. 'permissionRules>approval', 'userTool>interaction', - 'permissionPolicy>plan', - 'permissionPolicy>swarm', 'skill>loop', // `activityView` seeds its background-task slice once from the agent's task // registry (a read, never a write) — everything else it folds from events. diff --git a/packages/agent-core-v2/src/agent/goal/goalService.ts b/packages/agent-core-v2/src/agent/goal/goalService.ts index 2a686a8171..486ec54267 100644 --- a/packages/agent-core-v2/src/agent/goal/goalService.ts +++ b/packages/agent-core-v2/src/agent/goal/goalService.ts @@ -22,7 +22,12 @@ * `toolExecutor`, writes system reminders through `systemReminder`, reports * telemetry through `telemetry`, and checks main-agent eligibility through * `scopeContext`. Measures time and arms hard deadlines through `goal`'s - * App-scoped deadline scheduler. Bound at Agent scope. + * App-scoped deadline scheduler. Runs the goal-start review as a + * `toolExecutor` hook (`'goal-start-review'`, ordered before `'permission'`): + * a `CreateGoal` call carrying a `goal_start` display outside `auto` mode is + * approved through `toolApproval` under the origin `goal-start-review-ask` — + * including the permission-mode switch picked on the approval surface — and + * never falls through to the permission policy chain. Bound at Agent scope. * Subagent instances reject every goal command and do not install goal * injection, accounting, budget, or continuation hooks. */ @@ -49,6 +54,10 @@ import { ContinuationStepRequest, MessageStepRequest } from '#/agent/loop/stepRe import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import type { ExecutableToolResult } from '#/tool/toolContract'; +import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { ToolBeforeExecuteContext } from '#/agent/toolExecutor/toolHooks'; import { IAgentUsageService, type UsageRecordedContext } from '#/agent/usage/usage'; @@ -225,10 +234,13 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { @IAgentContextInjectorService dynamicInjector: IAgentContextInjectorService, @IAgentLoopService private readonly loopService: IAgentLoopService, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @IAgentPermissionModeService private readonly permissionMode: IAgentPermissionModeService, @IAgentUsageService usageService: IAgentUsageService, @IConfigService private readonly config: IConfigService, @IGoalDeadlineScheduler private readonly deadlineScheduler: IGoalDeadlineScheduler, @IAgentScopeContext private readonly agentContext: IAgentScopeContext, + @IAgentPermissionGate _permissionGate: IAgentPermissionGate, ) { super(); if (!this.isSupportedAgent) return; @@ -266,20 +278,58 @@ export class AgentGoalService extends Disposable implements IAgentGoalService { await next(); }), ); + // Injecting the gate forces it to be constructed first, so the + // `'permission'` hook target always exists for the registration below. this._register( - toolExecutor.hooks.onBeforeExecuteTool.register('goal-budget-reject', async (ctx, next) => { - if (this.isStaleGoalToolCall(ctx)) { - ctx.decision = { syntheticResult: { output: GOAL_STALE_TOOL_RESULT } }; - return; - } - if (this.budgetGraceTurns.has(ctx.turnId)) { - ctx.decision = { - syntheticResult: { output: GOAL_BUDGET_TOOLS_REJECTED_MESSAGE }, - }; - return; - } - await next(); - }), + toolExecutor.hooks.onBeforeExecuteTool.register( + 'goal-start-review', + async (ctx, next) => { + if ( + ctx.toolCall.name !== 'CreateGoal' || + this.permissionMode.mode === 'auto' || + ctx.execution.display?.kind !== 'goal_start' + ) { + await next(); + return; + } + const result = await this.toolApproval.requestToolApproval( + ctx, + { + kind: 'ask', + resolveApproval: (approval) => { + if (approval.decision !== 'approved') return undefined; + const mode = toGoalStartReviewPermissionMode(approval.selectedLabel); + if (mode !== undefined && mode !== this.permissionMode.mode) { + this.permissionMode.setMode(mode); + } + return undefined; + }, + }, + 'goal-start-review-ask', + ); + if (result !== undefined) ctx.decision = result; + }, + { before: 'permission' }, + ), + ); + this._register( + toolExecutor.hooks.onBeforeExecuteTool.register( + 'goal-budget-reject', + async (ctx, next) => { + if (this.isStaleGoalToolCall(ctx)) { + ctx.decision = { syntheticResult: { output: GOAL_STALE_TOOL_RESULT } }; + return; + } + if (this.budgetGraceTurns.has(ctx.turnId)) { + ctx.decision = { + syntheticResult: { output: GOAL_BUDGET_TOOLS_REJECTED_MESSAGE }, + }; + return; + } + await next(); + }, + { before: 'goal-start-review' }, + ), ); this._register( toolExecutor.hooks.onDidExecuteTool.register('goal-outcome-tool-result', async (ctx, next) => { @@ -1062,6 +1112,11 @@ function isGoalMutationTool(toolName: string): boolean { return toolName === 'CreateGoal' || toolName === 'UpdateGoal' || toolName === 'SetGoalBudget'; } +function toGoalStartReviewPermissionMode(label: string | undefined): PermissionMode | undefined { + if (label === 'auto' || label === 'yolo' || label === 'manual') return label; + return undefined; +} + function goalBudgetBlockReason(budget: GoalBudgetReport): string | undefined { const reached: string[] = []; if (budget.turnBudgetReached) { diff --git a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts index 63c266bc23..1d6f0912ac 100644 --- a/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts +++ b/packages/agent-core-v2/src/agent/permissionGate/permissionGateService.ts @@ -1,67 +1,40 @@ +/** + * `permissionGate` domain (L3) — `IAgentPermissionGate` implementation. + * + * Runs the `permissionPolicy` chain for every tool execution through the + * executor's `onBeforeExecuteTool` hook (registered as `'permission'`), reports + * `permission_policy_decision` through `telemetry`, and delegates the ask + * round-trip (broker, events, session-rule recording) to `toolApproval`. + * Harness constraints (plan guard, swarm exclusivity, btw deny) live in their + * own domains as executor hooks ordered `before: 'permission'` — this gate + * only adjudicates risk. Bound at Agent scope. + */ + import { InstantiationType } from '#/_base/di/extensions'; -import { IInstantiationService } from "#/_base/di/instantiation"; -import { Disposable } from "#/_base/di/lifecycle"; +import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { abortable, isUserCancellation } from '#/_base/utils/abort'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; -import type { - ApprovalRequest, - ApprovalResponse, - PermissionData, - PermissionPolicyResolution, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; +import type { PermissionData } from '#/agent/permissionPolicy/types'; import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import type { AuthorizeToolExecutionResult, ResolvedToolExecutionHookContext, } from '#/agent/toolExecutor/toolHooks'; -import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { IEventBus } from '#/app/event/eventBus'; import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { ISessionApprovalService } from "#/session/approval/approval"; -import { ISessionContext } from '#/session/sessionContext/sessionContext'; -import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import { - IAgentPermissionGate, -} from './permissionGate'; -export type PermissionApprovalRequestContext = ApprovalRequest & { - readonly sessionId?: string; - readonly agentId?: string; - readonly turnId: number; - readonly toolInput: unknown; -}; - -export type PermissionApprovalResultContext = PermissionApprovalRequestContext & - ( - | ApprovalResponse - | { - readonly decision: 'error'; - readonly error: string; - } - ); - -declare module '#/app/event/eventBus' { - interface DomainEventMap { - 'permission.approval.requested': PermissionApprovalRequestContext; - 'permission.approval.resolved': PermissionApprovalResultContext; - } -} +import { IAgentPermissionGate } from './permissionGate'; export class AgentPermissionGate extends Disposable implements IAgentPermissionGate { declare readonly _serviceBrand: undefined; constructor( - @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, @IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService, @IAgentPermissionPolicyService private readonly policyService: IAgentPermissionPolicyService, - @ISessionContext private readonly session: ISessionContext, - @IInstantiationService private readonly instantiation: IInstantiationService, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, @ITelemetryService private readonly telemetry: ITelemetryService, - @IEventBus private readonly eventBus: IEventBus, @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, ) { super(); @@ -98,195 +71,12 @@ export class AgentPermissionGate extends Disposable implements IAgentPermissionG decision: evaluation.result.kind, ...evaluation.result.reason, }); - return this.permissionPolicyResolutionToAuthorize( + return this.toolApproval.resolvePermissionResolution( evaluation.result, context, evaluation.policyName, ); } - - private async permissionPolicyResolutionToAuthorize( - result: PermissionPolicyResolution, - context: ResolvedToolExecutionHookContext, - policyName?: string, - ): Promise { - switch (result.kind) { - case 'approve': - return result.executionMetadata === undefined - ? undefined - : { executionMetadata: result.executionMetadata }; - case 'deny': - return { - block: true, - reason: this.formatDenyMessage( - result.message ?? `Tool "${context.toolCall.name}" was denied by permission policy.`, - ), - }; - case 'ask': - return this.requestToolApproval(context, result, policyName); - case 'result': { - const { kind: _kind, ...authorizeResult } = result; - return authorizeResult; - } - } - } - - private async requestToolApproval( - context: ResolvedToolExecutionHookContext, - result: Extract, - policyName: string | undefined, - ): Promise { - const name = context.toolCall.name; - const action = context.execution.description ?? `Approve ${name}`; - const display = - context.execution.display ?? - ({ - kind: 'generic', - summary: action, - detail: context.args, - } as ToolInputDisplay); - const approvalRequest = { - sessionId: this.session.sessionId, - agentId: this.scopeContext.agentId, - turnId: context.turnId, - toolCallId: context.toolCall.id, - toolName: name, - action, - display, - }; - const approvalContext = { - ...approvalRequest, - toolInput: context.args, - } satisfies PermissionApprovalRequestContext; - const startedAt = Date.now(); - - let response: ApprovalResponse; - const approvalService = this.tryApprovalService(); - if (approvalService === undefined) { - response = { decision: 'approved' }; - } else { - this.eventBus.publish({ type: 'permission.approval.requested', ...approvalContext }); - try { - response = await abortable( - approvalService.request(approvalRequest), - context.signal, - ); - context.signal.throwIfAborted(); - } catch (error) { - if (isUserCancellation(error)) throw error; - this.telemetry.track2('permission_approval_result', { - turn_id: context.turnId, - tool_call_id: context.toolCall.id, - policy_name: policyName ?? null, - tool_name: name, - permission_mode: this.modeService.mode, - result: 'error', - approval_surface: display.kind, - duration_ms: Date.now() - startedAt, - session_cache_written: false, - has_feedback: false, - trace_id: context.trace?.traceId, - }); - this.eventBus.publish({ - type: 'permission.approval.resolved', - ...approvalContext, - decision: 'error', - error: error instanceof Error ? error.message : String(error), - }); - const resolved = result.resolveError?.(error); - if (resolved !== undefined) { - return this.permissionPolicyResolutionToAuthorize(resolved, context, policyName); - } - throw error; - } - } - - const sessionApprovalRule = - response.decision === 'approved' && response.scope === 'session' - ? context.execution.approvalRule - : undefined; - if (approvalService !== undefined) { - this.eventBus.publish({ - type: 'permission.approval.resolved', - ...approvalContext, - ...response, - }); - } - this.rulesService.recordApprovalResult({ - turnId: context.turnId, - toolCallId: context.toolCall.id, - toolName: name, - action, - sessionApprovalRule, - result: response, - }); - this.telemetry.track2('permission_approval_result', { - turn_id: context.turnId, - tool_call_id: context.toolCall.id, - policy_name: policyName ?? null, - tool_name: name, - permission_mode: this.modeService.mode, - result: - response.decision === 'approved' && response.scope === 'session' - ? 'approved_for_session' - : response.decision, - approval_surface: display.kind, - duration_ms: Date.now() - startedAt, - session_cache_written: sessionApprovalRule !== undefined, - has_feedback: response.feedback !== undefined && response.feedback.length > 0, - trace_id: context.trace?.traceId, - }); - - const resolved = result.resolveApproval?.(response); - if (resolved !== undefined) { - return this.permissionPolicyResolutionToAuthorize(resolved, context, policyName); - } - - if (response.decision === 'approved') return undefined; - return { - block: true, - reason: this.formatApprovalRejectionMessage(name, response), - }; - } - - private tryApprovalService(): ISessionApprovalService | undefined { - try { - return this.instantiation.invokeFunction( - (accessor) => accessor.get(ISessionApprovalService) as ISessionApprovalService | undefined, - ); - } catch { - return undefined; - } - } - - private formatApprovalRejectionMessage( - toolName: string, - result: { decision: 'approved' | 'rejected' | 'cancelled'; feedback?: string }, - ): string { - const suffix = - result.feedback !== undefined && result.feedback.length > 0 - ? ` Reason: ${result.feedback}` - : ''; - const prefix = - result.decision === 'cancelled' - ? `Tool "${toolName}" was not run because the approval request was cancelled.` - : `Tool "${toolName}" was not run because the user rejected the approval request.`; - if (this.usesWorkerRejectionGuidance()) { - return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; - } - return `${prefix}${suffix}`; - } - - private formatDenyMessage(message: string): string { - if (this.usesWorkerRejectionGuidance()) { - return `${message} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; - } - return message; - } - - private usesWorkerRejectionGuidance(): boolean { - return this.scopeContext.agentId !== 'main'; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts index 89b0c7d465..d5c7ea3018 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicy.ts @@ -1,9 +1,8 @@ import { createDecorator } from "#/_base/di/instantiation"; -import { type IDisposable } from "#/_base/di/lifecycle"; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { PermissionPolicy, PermissionPolicyResult } from './types'; +import type { PermissionPolicyResult } from './types'; export interface PermissionPolicyEvaluation { @@ -17,7 +16,6 @@ export interface IAgentPermissionPolicyService { evaluate( context: ResolvedToolExecutionHookContext, ): Promise; - registerPolicy(policy: PermissionPolicy): IDisposable; } export const IAgentPermissionPolicyService = diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts index ee09967cab..49408988de 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/permissionPolicyService.ts @@ -1,20 +1,25 @@ +/** + * `permissionPolicy` domain (L3) — `IAgentPermissionPolicyService` implementation. + * + * Runs the static, ordered permission chain: every node adjudicates the *risk* + * of a tool call (mode posture, user rules, session approval memory, sensitive + * paths, intrinsic tool risk, workspace write trust, fallback). Harness + * constraints (plan guard, swarm batch exclusivity, btw deny) are NOT here — + * they live in their owning domains as executor hooks ordered + * `before: 'permission'`. Bound at Agent scope. + */ + import { IInstantiationService } from "#/_base/di/instantiation"; -import { Disposable, type IDisposable } from "#/_base/di/lifecycle"; +import { Disposable } from "#/_base/di/lifecycle"; import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { AgentSwarmExclusiveDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/agent-swarm-exclusive-deny'; import { AutoModeApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-approve'; import { AutoModeAskUserQuestionDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/auto-mode-ask-user-question-deny'; import { DefaultToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/default-tool-approve'; -import { ExitPlanModeReviewAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/exit-plan-mode-review-ask'; import { FallbackAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/fallback-ask'; import { GitControlPathAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/git-control-path-access-ask'; import { GitCwdWriteApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/git-cwd-write-approve'; -import { GoalStartReviewAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/goal-start-review-ask'; -import { PlanModeGuardDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/plan-mode-guard-deny'; -import { PlanModeToolApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/plan-mode-tool-approve'; import { SensitiveFileAccessAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/sensitive-file-access-ask'; import { SessionApprovalHistoryPermissionPolicyService } from '#/agent/permissionPolicy/policies/session-approval-history'; -import { SwarmModeAgentSwarmApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve'; import { UserConfiguredAllowPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-allow'; import { UserConfiguredAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-ask'; import { UserConfiguredDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/user-configured-deny'; @@ -34,28 +39,21 @@ export class AgentPermissionPolicyService declare readonly _serviceBrand: undefined; private readonly policies: readonly PermissionPolicy[]; - private readonly dynamicPolicies: PermissionPolicy[] = []; constructor( @IInstantiationService private readonly instantiation: IInstantiationService, ) { super(); this.policies = [ - this.instantiation.createInstance(AgentSwarmExclusiveDenyPermissionPolicyService), this.instantiation.createInstance(AutoModeAskUserQuestionDenyPermissionPolicyService), - this.instantiation.createInstance(PlanModeGuardDenyPermissionPolicyService), this.instantiation.createInstance(UserConfiguredDenyPermissionPolicyService), this.instantiation.createInstance(AutoModeApprovePermissionPolicyService), this.instantiation.createInstance(SessionApprovalHistoryPermissionPolicyService), this.instantiation.createInstance(UserConfiguredAskPermissionPolicyService), this.instantiation.createInstance(UserConfiguredAllowPermissionPolicyService), - this.instantiation.createInstance(ExitPlanModeReviewAskPermissionPolicyService), - this.instantiation.createInstance(GoalStartReviewAskPermissionPolicyService), - this.instantiation.createInstance(PlanModeToolApprovePermissionPolicyService), this.instantiation.createInstance(SensitiveFileAccessAskPermissionPolicyService), this.instantiation.createInstance(GitControlPathAccessAskPermissionPolicyService), this.instantiation.createInstance(YoloModeApprovePermissionPolicyService), - this.instantiation.createInstance(SwarmModeAgentSwarmApprovePermissionPolicyService), this.instantiation.createInstance(DefaultToolApprovePermissionPolicyService), this.instantiation.createInstance(GitCwdWriteApprovePermissionPolicyService), this.instantiation.createInstance(FallbackAskPermissionPolicyService), @@ -65,28 +63,12 @@ export class AgentPermissionPolicyService async evaluate( context: ResolvedToolExecutionHookContext, ): Promise { - for (const policy of this.dynamicPolicies) { - const result = await policy.evaluate(context); - if (result !== undefined) return { policyName: policy.name, result }; - } for (const policy of this.policies) { const result = await policy.evaluate(context); if (result !== undefined) return { policyName: policy.name, result }; } return undefined; } - - registerPolicy(policy: PermissionPolicy): IDisposable { - this.dynamicPolicies.unshift(policy); - const disposable = { - dispose: (): void => { - const index = this.dynamicPolicies.indexOf(policy); - if (index >= 0) this.dynamicPolicies.splice(index, 1); - }, - }; - this._register(disposable); - return disposable; - } } registerScopedService( diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/agent-swarm-exclusive-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/agent-swarm-exclusive-deny.ts deleted file mode 100644 index 1500e9f02c..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/agent-swarm-exclusive-deny.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { - PermissionPolicy, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; - -export class AgentSwarmExclusiveDenyPermissionPolicyService implements PermissionPolicy { - readonly name = 'agent-swarm-exclusive-deny'; - - evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - const agentSwarmCount = context.toolCalls.filter( - (toolCall) => toolCall.name === 'AgentSwarm', - ).length; - if (agentSwarmCount === 0) return undefined; - if (agentSwarmCount === 1 && context.toolCalls.length === 1) return undefined; - - return { - kind: 'deny', - message: - agentSwarmCount > 1 - ? multipleAgentSwarmDeniedMessage(context.toolCalls.length > agentSwarmCount) - : mixedAgentSwarmDeniedMessage(), - reason: { - agent_swarm_tool_calls: agentSwarmCount, - tool_calls: context.toolCalls.length, - }, - }; - } -} - -function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string { - const suffix = hasOtherToolCalls - ? ' AgentSwarm also must not be combined with other tools in the same response.' - : ''; - return ( - 'AgentSwarm must be called one swarm at a time. Multiple AgentSwarm calls are not forbidden, ' + - 'but issue them sequentially: call one AgentSwarm, wait for its result, then call the next; ' + - `or merge the work into a single AgentSwarm when one swarm can cover it.${suffix}` - ); -} - -function mixedAgentSwarmDeniedMessage(): string { - return ( - 'AgentSwarm must be the only tool call in a model response. Retry with a single AgentSwarm ' + - 'call by itself, then call any other tools after it returns.' - ); -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts index 3b0360dc4c..4867b0e00c 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts @@ -17,8 +17,12 @@ const DEFAULT_APPROVE_TOOLS = new Set([ 'WebSearch', 'FetchURL', 'Agent', + 'AgentSwarm', 'AskUserQuestion', 'Skill', + 'EnterPlanMode', + 'ExitPlanMode', + 'CreateGoal', 'GetGoal', 'SetGoalBudget', 'UpdateGoal', diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts deleted file mode 100644 index 02f7d086ac..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/deny-all.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { - PermissionPolicy, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; - -const DEFAULT_MESSAGE = 'Tool calls are disabled for this agent.'; - -export class DenyAllPermissionPolicyService implements PermissionPolicy { - readonly name = 'deny-all'; - - constructor(private readonly message: string = DEFAULT_MESSAGE) {} - - evaluate(): PermissionPolicyResult { - return { kind: 'deny', message: this.message }; - } -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/goal-start-review-ask.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/goal-start-review-ask.ts deleted file mode 100644 index c48358c7a7..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/goal-start-review-ask.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import type { - PermissionPolicy, - PermissionMode, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; - -export class GoalStartReviewAskPermissionPolicyService implements PermissionPolicy { - readonly name = 'goal-start-review-ask'; - - constructor( - @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, - ) {} - - evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - if (context.toolCall.name !== 'CreateGoal') return undefined; - if (this.modeService.mode === 'auto') return undefined; - if (context.execution.display?.kind !== 'goal_start') return undefined; - return { - kind: 'ask', - resolveApproval: (result) => { - if (result.decision !== 'approved') return undefined; - const mode = toPermissionMode(result.selectedLabel); - if (mode !== undefined && mode !== this.modeService.mode) { - this.modeService.setMode(mode); - } - return undefined; - }, - }; - } -} - -function toPermissionMode(label: string | undefined): PermissionMode | undefined { - if (label === 'auto' || label === 'yolo' || label === 'manual') return label; - return undefined; -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts index 9291ccd87b..529fbdf616 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/path-utils.ts @@ -28,15 +28,6 @@ export function writeFileAccesses(context: ResolvedToolExecutionHookContext): To ); } -export function writesOnlyPlanFile( - context: ResolvedToolExecutionHookContext, - planFilePath: string, -): boolean { - const writeAccesses = writeFileAccesses(context); - if (writeAccesses.length === 0) return false; - return writeAccesses.every((access) => access.path === planFilePath); -} - export function hasGitPathComponent( targetPath: string, cwd: string, diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-guard-deny.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-guard-deny.ts deleted file mode 100644 index 7d42b34e93..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-guard-deny.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { - PermissionPolicy, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; -import { - writesOnlyPlanFile, -} from './path-utils'; - -export class PlanModeGuardDenyPermissionPolicyService implements PermissionPolicy { - readonly name = 'plan-mode-guard-deny'; - - constructor(@IAgentPlanService private readonly plan: AgentPlanService) {} - - async evaluate( - context: ResolvedToolExecutionHookContext, - ): Promise { - const plan = await this.plan.status(); - if (plan === null) return undefined; - - const toolName = context.toolCall.name; - if (toolName === 'Write' || toolName === 'Edit') { - const planFilePath = plan.path; - if (planFilePath !== null && writesOnlyPlanFile(context, planFilePath)) return undefined; - return { - kind: 'deny', - message: planModeWriteDeniedMessage(planFilePath), - }; - } - - if (toolName === 'TaskStop') { - return { - kind: 'deny', - message: - 'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.', - }; - } - - if (toolName === 'CronCreate' || toolName === 'CronDelete') { - return { - kind: 'deny', - message: - `${toolName} is not available in plan mode because it would mutate scheduled work that runs after plan exit. Call ExitPlanMode first.`, - }; - } - - return undefined; - } -} - -function planModeWriteDeniedMessage(planFilePath: string | null): string { - return ( - `Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` + - 'Call ExitPlanMode to exit plan mode before editing other files.' - ); -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-tool-approve.ts deleted file mode 100644 index 6f3e8349f3..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/plan-mode-tool-approve.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { - PermissionPolicy, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; -import { writesOnlyPlanFile } from './path-utils'; - -export class PlanModeToolApprovePermissionPolicyService implements PermissionPolicy { - readonly name = 'plan-mode-tool-approve'; - - constructor(@IAgentPlanService private readonly plan: AgentPlanService) {} - - async evaluate( - context: ResolvedToolExecutionHookContext, - ): Promise { - const toolName = context.toolCall.name; - if (toolName === 'EnterPlanMode') return { kind: 'approve' }; - - const plan = await this.plan.status(); - const planFilePath = plan?.path ?? null; - if ( - (toolName === 'Write' || toolName === 'Edit') && - plan !== null && - planFilePath !== null && - writesOnlyPlanFile(context, planFilePath) - ) { - return { kind: 'approve' }; - } - - if (toolName === 'ExitPlanMode') { - if (plan === null) return { kind: 'approve' }; - if (context.execution.display?.kind !== 'plan_review') return { kind: 'approve' }; - if (context.execution.display.plan.trim().length === 0) return { kind: 'approve' }; - } - - return undefined; - } -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts deleted file mode 100644 index 3ffad54176..0000000000 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import type { IAgentSwarmService as AgentSwarmService } from '#/agent/swarm/swarm'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { - PermissionPolicy, - PermissionPolicyResult, -} from '#/agent/permissionPolicy/types'; - -export class SwarmModeAgentSwarmApprovePermissionPolicyService implements PermissionPolicy { - readonly name = 'swarm-mode-agent-swarm-approve'; - - constructor(@IAgentSwarmService private readonly swarm: AgentSwarmService) {} - - evaluate(context: ResolvedToolExecutionHookContext): PermissionPolicyResult | undefined { - if (context.toolCall.name !== 'AgentSwarm') return undefined; - return this.swarm.isActive ? { kind: 'approve' } : undefined; - } -} diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts b/packages/agent-core-v2/src/agent/plan/exitPlanModeReview.ts similarity index 69% rename from packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts rename to packages/agent-core-v2/src/agent/plan/exitPlanModeReview.ts index 29c0b98e15..ccfc0b909e 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/exit-plan-mode-review-ask.ts +++ b/packages/agent-core-v2/src/agent/plan/exitPlanModeReview.ts @@ -1,67 +1,70 @@ -import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import type { PlanResolvedEvent, PlanSubmittedEvent } from '#/app/telemetry/events'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; +/** + * `plan` domain (L3) — ExitPlanMode plan review. + * + * Owns the user-facing review that intercepts an `ExitPlanMode` call carrying + * a non-empty `plan_review` display: emits `plan_submitted` / `plan_resolved` + * through `telemetry`, drives the approval round-trip through `toolApproval` + * (origin `exit-plan-mode-review-ask`, matching the legacy permission + * policy's telemetry), and folds every approval outcome (approve with or + * without a selected option, Revise with feedback, Reject and Exit, dismiss) + * into a synthetic tool result, exiting plan mode through `plan` when the + * outcome deactivates it. Consumed by `planService`'s `'plan-guard'` executor + * hook; the mode / plan-active gating stays in the hook. + */ + import type { - PermissionPolicy, - PermissionPolicyResolution, - PermissionPolicyResult, ApprovalResponse, + PermissionPolicyResolution, } from '#/agent/permissionPolicy/types'; +import type { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import type { + AuthorizeToolExecutionResult, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; +import type { PlanResolvedEvent, PlanSubmittedEvent } from '#/app/telemetry/events'; +import type { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -interface PlanReviewOption { - readonly label: string; - readonly description: string; -} - -interface PlanReviewDisplay { - readonly plan: string; - readonly path?: string | undefined; - readonly options?: readonly PlanReviewOption[] | undefined; -} +import type { IAgentPlanService } from './plan'; -export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionPolicy { - readonly name = 'exit-plan-mode-review-ask'; +type PlanReviewDisplay = Extract; +type PlanReviewOption = NonNullable[number]; +export class ExitPlanModeReview { constructor( - @IAgentPlanService private readonly plan: AgentPlanService, - @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, - @ITelemetryService private readonly telemetry: ITelemetryService, + private readonly plan: IAgentPlanService, + private readonly toolApproval: IAgentToolApprovalService, + private readonly telemetry: ITelemetryService, ) {} - async evaluate( + async requestApproval( context: ResolvedToolExecutionHookContext, - ): Promise { - if (context.toolCall.name !== 'ExitPlanMode') return undefined; - if (this.modeService.mode === 'auto') return undefined; - if (await this.plan.status() === null) return undefined; + ): Promise { const display = context.execution.display; if (display?.kind !== 'plan_review') return undefined; if (display.plan.trim().length === 0) return undefined; this.trackPlanTelemetry('plan_submitted', { has_options: display.options !== undefined && display.options.length >= 2, }); - return { - kind: 'ask', - reason: { - has_options: display.options !== undefined, + return this.toolApproval.requestToolApproval( + context, + { + kind: 'ask', + reason: { + has_options: display.options !== undefined, + }, + resolveApproval: (result) => this.approvalResult(result, display), }, - resolveApproval: (result) => - this.exitPlanModeApprovalResult(result, { - plan: display.plan, - path: display.path, - options: display.options, - }), - }; + 'exit-plan-mode-review-ask', + ); } - private exitPlanModeApprovalResult( + private approvalResult( result: ApprovalResponse, display: PlanReviewDisplay, ): PermissionPolicyResolution | undefined { if (result.decision !== 'approved') { - return this.rejectedExitPlanModeApprovalResult(result); + return this.rejectedApprovalResult(result); } const selected = selectedExitPlanModeOption(display.options, result.selectedLabel); @@ -91,9 +94,7 @@ export class ExitPlanModeReviewAskPermissionPolicyService implements PermissionP }; } - private rejectedExitPlanModeApprovalResult( - result: ApprovalResponse, - ): PermissionPolicyResolution { + private rejectedApprovalResult(result: ApprovalResponse): PermissionPolicyResolution { this.trackRejectedPlanResolution(result); if (result.decision === 'cancelled') { diff --git a/packages/agent-core-v2/src/agent/plan/planService.ts b/packages/agent-core-v2/src/agent/plan/planService.ts index 9c63cec358..d2e1ce6614 100644 --- a/packages/agent-core-v2/src/agent/plan/planService.ts +++ b/packages/agent-core-v2/src/agent/plan/planService.ts @@ -3,30 +3,50 @@ * * Manages plan-mode state through `wire`, injects plan-mode context through * `contextInjector`, writes optional plan files through `hostFileSystem`, - * and tags mode telemetry through `telemetry`. Bound at Agent scope. + * and tags mode telemetry through `telemetry`. Also carries the plan-mode + * Harness constraints as an executor `onBeforeExecuteTool` hook + * (`'plan-guard'`, ordered before `'permission'` whenever the gate's hook + * already exists): while a plan is active, Write/Edit calls targeting only + * the current plan file are let through without the permission chain, any + * other Write/Edit and every TaskStop/CronCreate/CronDelete call is blocked + * with a `toolApproval.formatDenyMessage`-formatted reason, and an + * `ExitPlanMode` call outside `auto` mode goes through the + * `exitPlanModeReview` user review instead of the permission chain. Bound at + * Agent scope. */ import { randomUUID } from 'node:crypto'; import { dirname, join } from 'pathe'; -import { Disposable } from '#/_base/di/lifecycle'; +import { Disposable, type IDisposable } from '#/_base/di/lifecycle'; import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { unwrapErrorCause } from '#/_base/errors/errors'; import { generateHeroSlug } from '#/_base/utils/hero-slug'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { PlanModeInjection } from '#/agent/plan/injection/planModeInjection'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + ResolvedToolExecutionHookContext, + ToolBeforeExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { IWireService } from '#/wire/wire'; +import type { ToolFileAccess } from '#/tool/toolContract'; +import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate'; import { IAgentPlanService, type PlanData, type PlanFilePath, } from './plan'; +import { ExitPlanModeReview } from './exitPlanModeReview'; import { PlanModel, planModeCancel, @@ -37,6 +57,8 @@ import { export class AgentPlanService extends Disposable implements IAgentPlanService { declare readonly _serviceBrand: undefined; + private readonly review: ExitPlanModeReview; + constructor( @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IHostFileSystem private readonly hostFs: IHostFileSystem, @@ -45,9 +67,16 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { @IWireService private readonly wire: IWireService, @ISessionContext private readonly sessionCtx: ISessionContext, @IAgentScopeContext private readonly agentCtx: IAgentScopeContext, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @ITelemetryService telemetry: ITelemetryService, + @IAgentPermissionGate _permissionGate: IAgentPermissionGate, ) { super(); + this.review = new ExitPlanModeReview(this, this.toolApproval, telemetry); + this._register( this.wire.hooks.onDidRestore.register('plan', async (_ctx, next) => { this.restoreTelemetryMode(); @@ -56,6 +85,79 @@ export class AgentPlanService extends Disposable implements IAgentPlanService { ); this._register(new PlanModeInjection(dynamicInjector, this, this.context)); + this._register(this.registerPlanGuard(toolExecutor)); + } + + // Injecting the gate above forces it to be constructed first, so the + // `'permission'` hook is always registered when this hook lands ahead of it. + private registerPlanGuard(toolExecutor: IAgentToolExecutorService): IDisposable { + const slot = toolExecutor.hooks.onBeforeExecuteTool; + const handler = async ( + ctx: ToolBeforeExecuteContext, + next: () => Promise, + ): Promise => { + await this.guardToolExecution(ctx, next); + }; + return slot.register('plan-guard', handler, { before: 'permission' }); + } + + private async guardToolExecution( + ctx: ToolBeforeExecuteContext, + next: () => Promise, + ): Promise { + const toolName = ctx.toolCall.name; + const plan = await this.status(); + + if (toolName === 'ExitPlanMode') { + if (plan !== null && this.modeService.mode !== 'auto') { + const decision = await this.review.requestApproval(ctx); + if (decision !== undefined) { + ctx.decision = decision; + if (decision.block === true || decision.syntheticResult !== undefined) return; + } + } + await next(); + return; + } + + if (plan === null) { + await next(); + return; + } + + if (toolName === 'Write' || toolName === 'Edit') { + if (writesOnlyPlanFile(ctx, plan.path)) { + ctx.decision = {}; + return; + } + ctx.decision = { + block: true, + reason: this.toolApproval.formatDenyMessage(planModeWriteDeniedMessage(plan.path)), + }; + return; + } + + if (toolName === 'TaskStop') { + ctx.decision = { + block: true, + reason: this.toolApproval.formatDenyMessage( + 'TaskStop is not available in plan mode. Call ExitPlanMode to exit plan mode before stopping a background task.', + ), + }; + return; + } + + if (toolName === 'CronCreate' || toolName === 'CronDelete') { + ctx.decision = { + block: true, + reason: this.toolApproval.formatDenyMessage( + `${toolName} is not available in plan mode because it would mutate scheduled work that runs after plan exit. Call ExitPlanMode first.`, + ), + }; + return; + } + + await next(); } private get isActive(): boolean { @@ -155,6 +257,26 @@ function isMissingFileError(error: unknown): boolean { return code === 'ENOENT'; } +function writesOnlyPlanFile( + context: ResolvedToolExecutionHookContext, + planFilePath: string, +): boolean { + const writeAccesses = (context.execution.accesses ?? []).filter( + (access): access is ToolFileAccess => + access.kind === 'file' && + (access.operation === 'write' || access.operation === 'readwrite'), + ); + if (writeAccesses.length === 0) return false; + return writeAccesses.every((access) => access.path === planFilePath); +} + +function planModeWriteDeniedMessage(planFilePath: string | null): string { + return ( + `Plan mode is active. You may only write to the current plan file: ${planFilePath ?? '(no plan file selected yet)'}. ` + + 'Call ExitPlanMode to exit plan mode before editing other files.' + ); +} + export { AgentPlanService as Plan }; registerScopedService( diff --git a/packages/agent-core-v2/src/agent/swarm/swarmService.ts b/packages/agent-core-v2/src/agent/swarm/swarmService.ts index 4ed949ff78..54a0ba7d53 100644 --- a/packages/agent-core-v2/src/agent/swarm/swarmService.ts +++ b/packages/agent-core-v2/src/agent/swarm/swarmService.ts @@ -12,7 +12,11 @@ * live-only `context.spliced` event for that pop (so injector bookkeeping * stays in step) and appends the exit reminder when nothing was * popped. Bound at Agent scope. The `AgentSwarm` tool self-registers via - * `registerTool(...)` in `tools/agent-swarm.ts`. + * `registerTool(...)` in `tools/agent-swarm.ts`. The service also guards + * AgentSwarm batch exclusivity through an executor `onBeforeExecuteTool` hook + * (`swarm-exclusive`, ordered before `permission`): an AgentSwarm call must be + * the only tool call in its batch, anything else is blocked with a + * `toolApproval.formatDenyMessage`-formatted reason. */ import { Disposable } from '#/_base/di/lifecycle'; @@ -20,7 +24,10 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IEventBus } from '#/app/event/eventBus'; +import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate'; import { IWireService } from '#/wire/wire'; import SWARM_MODE_ENTER_REMINDER from './enter-reminder.md?raw'; import SWARM_MODE_EXIT_REMINDER from './exit-reminder.md?raw'; @@ -35,6 +42,9 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService @IAgentSystemReminderService private readonly reminders: IAgentSystemReminderService, @IAgentContextMemoryService private readonly context: IAgentContextMemoryService, @IEventBus private readonly eventBus: IEventBus, + @IAgentToolApprovalService private readonly toolApproval: IAgentToolApprovalService, + @IAgentToolExecutorService toolExecutor: IAgentToolExecutorService, + @IAgentPermissionGate _permissionGate: IAgentPermissionGate, ) { super(); this._register( @@ -44,6 +54,29 @@ export class AgentSwarmService extends Disposable implements IAgentSwarmService } }), ); + this._register( + toolExecutor.hooks.onBeforeExecuteTool.register( + 'swarm-exclusive', + async (ctx, next) => { + const agentSwarmCount = ctx.toolCalls.filter( + (toolCall) => toolCall.name === 'AgentSwarm', + ).length; + if (agentSwarmCount === 0 || (agentSwarmCount === 1 && ctx.toolCalls.length === 1)) { + await next(); + return; + } + ctx.decision = { + block: true, + reason: this.toolApproval.formatDenyMessage( + agentSwarmCount > 1 + ? multipleAgentSwarmDeniedMessage(ctx.toolCalls.length > agentSwarmCount) + : mixedAgentSwarmDeniedMessage(), + ), + }; + }, + { before: 'permission' }, + ), + ); } enter(trigger: SwarmModeTrigger): void { @@ -98,3 +131,21 @@ registerScopedService( InstantiationType.Eager, 'swarm', ); + +function multipleAgentSwarmDeniedMessage(hasOtherToolCalls: boolean): string { + const suffix = hasOtherToolCalls + ? ' AgentSwarm also must not be combined with other tools in the same response.' + : ''; + return ( + 'AgentSwarm must be called one swarm at a time. Multiple AgentSwarm calls are not forbidden, ' + + 'but issue them sequentially: call one AgentSwarm, wait for its result, then call the next; ' + + `or merge the work into a single AgentSwarm when one swarm can cover it.${suffix}` + ); +} + +function mixedAgentSwarmDeniedMessage(): string { + return ( + 'AgentSwarm must be the only tool call in a model response. Retry with a single AgentSwarm ' + + 'call by itself, then call any other tools after it returns.' + ); +} diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts new file mode 100644 index 0000000000..9d4cc09f3b --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApproval.ts @@ -0,0 +1,48 @@ +/** + * `toolApproval` domain (L3) — `IAgentToolApprovalService` contract. + * + * Shared approval round-trip for tool executions: builds the approval request, + * drives the `session/approval` broker, emits the `permission.approval.*` + * events, records session-scope approval rules through `permissionRules`, and + * resolves ask continuations. Consumed by `permissionGate` (policy-chain asks) + * and by Harness domains such as `plan` / `goal` that run product reviews + * outside the permission chain. Bound at Agent scope. + */ + +import { createDecorator } from '#/_base/di/instantiation'; +import type { + ApprovalResponse, + PermissionPolicyResolution, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import type { + AuthorizeToolExecutionResult, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; + +export interface IAgentToolApprovalService { + readonly _serviceBrand: undefined; + + resolvePermissionResolution( + result: PermissionPolicyResolution, + context: ResolvedToolExecutionHookContext, + origin: string, + ): Promise; + + requestToolApproval( + context: ResolvedToolExecutionHookContext, + result: Extract, + origin: string, + ): Promise; + + formatDenyMessage(message: string): string; + + formatApprovalRejectionMessage( + toolName: string, + result: Pick, + ): string; +} + +export const IAgentToolApprovalService = createDecorator( + 'agentToolApprovalService', +); diff --git a/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts new file mode 100644 index 0000000000..3b0c6fcc99 --- /dev/null +++ b/packages/agent-core-v2/src/agent/toolApproval/toolApprovalService.ts @@ -0,0 +1,266 @@ +/** + * `toolApproval` domain (L3) — `IAgentToolApprovalService` implementation. + * + * Owns the approval round-trip extracted from `permissionGate`: publishes + * `permission.approval.requested/resolved` through `eventBus`, awaits the + * `session/approval` broker (absent broker = auto-approve), records + * session-scope approval rules through `permissionRules`, reports + * `permission_approval_result` through `telemetry`, and folds ask + * continuations back into authorize results. Bound at Agent scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { IInstantiationService } from '#/_base/di/instantiation'; +import { Disposable } from '#/_base/di/lifecycle'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { abortable, isUserCancellation } from '#/_base/utils/abort'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + ApprovalRequest, + ApprovalResponse, + PermissionPolicyResolution, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { IAgentPermissionRulesService } from '#/agent/permissionRules/permissionRules'; +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import type { + AuthorizeToolExecutionResult, + ResolvedToolExecutionHookContext, +} from '#/agent/toolExecutor/toolHooks'; +import { IEventBus } from '#/app/event/eventBus'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { ISessionApprovalService } from '#/session/approval/approval'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { IAgentToolApprovalService } from './toolApproval'; + +export type PermissionApprovalRequestContext = ApprovalRequest & { + readonly sessionId?: string; + readonly agentId?: string; + readonly turnId: number; + readonly toolInput: unknown; +}; + +export type PermissionApprovalResultContext = PermissionApprovalRequestContext & + ( + | ApprovalResponse + | { + readonly decision: 'error'; + readonly error: string; + } + ); + +declare module '#/app/event/eventBus' { + interface DomainEventMap { + 'permission.approval.requested': PermissionApprovalRequestContext; + 'permission.approval.resolved': PermissionApprovalResultContext; + } +} + +export class AgentToolApprovalService extends Disposable implements IAgentToolApprovalService { + declare readonly _serviceBrand: undefined; + + constructor( + @IAgentScopeContext private readonly scopeContext: IAgentScopeContext, + @IAgentPermissionModeService private readonly modeService: IAgentPermissionModeService, + @IAgentPermissionRulesService private readonly rulesService: IAgentPermissionRulesService, + @ISessionContext private readonly session: ISessionContext, + @IInstantiationService private readonly instantiation: IInstantiationService, + @ITelemetryService private readonly telemetry: ITelemetryService, + @IEventBus private readonly eventBus: IEventBus, + ) { + super(); + } + + async resolvePermissionResolution( + result: PermissionPolicyResolution, + context: ResolvedToolExecutionHookContext, + origin: string, + ): Promise { + switch (result.kind) { + case 'approve': + return result.executionMetadata === undefined + ? undefined + : { executionMetadata: result.executionMetadata }; + case 'deny': + return { + block: true, + reason: this.formatDenyMessage( + result.message ?? `Tool "${context.toolCall.name}" was denied by permission policy.`, + ), + }; + case 'ask': + return this.requestToolApproval(context, result, origin); + case 'result': { + const { kind: _kind, ...authorizeResult } = result; + return authorizeResult; + } + } + } + + async requestToolApproval( + context: ResolvedToolExecutionHookContext, + result: Extract, + origin: string, + ): Promise { + const name = context.toolCall.name; + const action = context.execution.description ?? `Approve ${name}`; + const display = + context.execution.display ?? + ({ + kind: 'generic', + summary: action, + detail: context.args, + } as ToolInputDisplay); + const approvalRequest = { + sessionId: this.session.sessionId, + agentId: this.scopeContext.agentId, + turnId: context.turnId, + toolCallId: context.toolCall.id, + toolName: name, + action, + display, + }; + const approvalContext = { + ...approvalRequest, + toolInput: context.args, + } satisfies PermissionApprovalRequestContext; + const startedAt = Date.now(); + + let response: ApprovalResponse; + const approvalService = this.tryApprovalService(); + if (approvalService === undefined) { + response = { decision: 'approved' }; + } else { + this.eventBus.publish({ type: 'permission.approval.requested', ...approvalContext }); + try { + response = await abortable( + approvalService.request(approvalRequest), + context.signal, + ); + context.signal.throwIfAborted(); + } catch (error) { + if (isUserCancellation(error)) throw error; + this.telemetry.track2('permission_approval_result', { + turn_id: context.turnId, + tool_call_id: context.toolCall.id, + policy_name: origin, + tool_name: name, + permission_mode: this.modeService.mode, + result: 'error', + approval_surface: display.kind, + duration_ms: Date.now() - startedAt, + session_cache_written: false, + has_feedback: false, + trace_id: context.trace?.traceId, + }); + this.eventBus.publish({ + type: 'permission.approval.resolved', + ...approvalContext, + decision: 'error', + error: error instanceof Error ? error.message : String(error), + }); + const resolved = result.resolveError?.(error); + if (resolved !== undefined) { + return this.resolvePermissionResolution(resolved, context, origin); + } + throw error; + } + } + + const sessionApprovalRule = + response.decision === 'approved' && response.scope === 'session' + ? context.execution.approvalRule + : undefined; + if (approvalService !== undefined) { + this.eventBus.publish({ + type: 'permission.approval.resolved', + ...approvalContext, + ...response, + }); + } + this.rulesService.recordApprovalResult({ + turnId: context.turnId, + toolCallId: context.toolCall.id, + toolName: name, + action, + sessionApprovalRule, + result: response, + }); + this.telemetry.track2('permission_approval_result', { + turn_id: context.turnId, + tool_call_id: context.toolCall.id, + policy_name: origin, + tool_name: name, + permission_mode: this.modeService.mode, + result: + response.decision === 'approved' && response.scope === 'session' + ? 'approved_for_session' + : response.decision, + approval_surface: display.kind, + duration_ms: Date.now() - startedAt, + session_cache_written: sessionApprovalRule !== undefined, + has_feedback: response.feedback !== undefined && response.feedback.length > 0, + trace_id: context.trace?.traceId, + }); + + const resolved = result.resolveApproval?.(response); + if (resolved !== undefined) { + return this.resolvePermissionResolution(resolved, context, origin); + } + + if (response.decision === 'approved') return undefined; + return { + block: true, + reason: this.formatApprovalRejectionMessage(name, response), + }; + } + + formatApprovalRejectionMessage( + toolName: string, + result: Pick, + ): string { + const suffix = + result.feedback !== undefined && result.feedback.length > 0 + ? ` Reason: ${result.feedback}` + : ''; + const prefix = + result.decision === 'cancelled' + ? `Tool "${toolName}" was not run because the approval request was cancelled.` + : `Tool "${toolName}" was not run because the user rejected the approval request.`; + if (this.usesWorkerRejectionGuidance()) { + return `${prefix}${suffix} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return `${prefix}${suffix}`; + } + + formatDenyMessage(message: string): string { + if (this.usesWorkerRejectionGuidance()) { + return `${message} Try a different approach — don't retry the same call, don't attempt to bypass the restriction.`; + } + return message; + } + + private tryApprovalService(): ISessionApprovalService | undefined { + try { + return this.instantiation.invokeFunction( + (accessor) => accessor.get(ISessionApprovalService) as ISessionApprovalService | undefined, + ); + } catch { + return undefined; + } + } + + private usesWorkerRejectionGuidance(): boolean { + return this.scopeContext.agentId !== 'main'; + } +} + +registerScopedService( + LifecycleScope.Agent, + IAgentToolApprovalService, + AgentToolApprovalService, + InstantiationType.Eager, + 'toolApproval', +); diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts index 21a420605e..1537a66500 100644 --- a/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts +++ b/packages/agent-core-v2/src/agent/toolExecutor/toolHooks.ts @@ -3,10 +3,11 @@ * * Defines the context objects passed through `IAgentToolExecutorService`'s * `onBeforeExecuteTool` / `onDidExecuteTool` hooks and the decision results - * handlers may return. Participants such as `permissionGate`, - * `permissionPolicy`, `toolDedupe`, `externalHooks`, `goal`, and `prompt` - * register through the executor's hook slots. Pure contract (types only); - * no scoped service. + * handlers may return. Participants such as `permissionGate`, `toolDedupe`, + * `externalHooks`, `goal`, `plan`, `swarm`, `btw`, and `prompt` register + * through the executor's hook slots; Harness constraints order themselves + * `before: 'permission'` (see the `permission` topic doc). Pure contract + * (types only); no scoped service. */ import type { ToolCall } from '#/kosong/contract/message'; diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 6f833e0b32..c7177697ed 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -182,6 +182,8 @@ export * from '#/session/sessionAgentProfileCatalog/extraFileAgentSource'; export * from '#/session/sessionAgentProfileCatalog/explicitFileAgentSource'; export * from '#/agent/permissionGate/permissionGate'; export * from '#/agent/permissionGate/permissionGateService'; +export * from '#/agent/toolApproval/toolApproval'; +export * from '#/agent/toolApproval/toolApprovalService'; import '#/app/flag/flag'; import '#/app/flag/flagRegistry'; import '#/app/flag/flagRegistryService'; @@ -440,7 +442,6 @@ export * from '#/agent/permissionMode/permissionModeService'; export * from '#/agent/permissionPolicy/permissionPolicy'; export * from '#/agent/permissionPolicy/permissionPolicyService'; export * from '#/agent/permissionPolicy/types'; -export * from '#/agent/permissionPolicy/policies/deny-all'; import '#/agent/permissionRules/configSection'; export * from '#/agent/permissionRules/permissionRules'; export * from '#/agent/permissionRules/matchesRule'; diff --git a/packages/agent-core-v2/src/session/btw/btwService.ts b/packages/agent-core-v2/src/session/btw/btwService.ts index 7a12a7dc06..a82276259a 100644 --- a/packages/agent-core-v2/src/session/btw/btwService.ts +++ b/packages/agent-core-v2/src/session/btw/btwService.ts @@ -2,8 +2,10 @@ * `btw` domain — `ISessionBtwService` implementation. * * Forks the main agent into a side-question child: inherits profile/context via - * `IAgentLifecycleService.fork`, then disables tool calls (deny-all permission - * policy) and appends the side-channel system reminder. Bound at Session scope — + * `IAgentLifecycleService.fork`, then disables tool calls via an executor + * `onBeforeExecuteTool` hook (`btw-deny-all`, blocks every tool call with the + * `toolApproval.formatDenyMessage`-formatted TOOL_CALL_DISABLED_MESSAGE) and + * appends the side-channel system reminder. Bound at Session scope — * `fork('main')` is a session-level operation, so the service injects the * session's `IAgentLifecycleService` directly rather than resolving it through * the main agent's accessor. Callers materialize the main agent first (the @@ -12,9 +14,9 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; -import { DenyAllPermissionPolicyService } from '#/agent/permissionPolicy/policies/deny-all'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER, TOOL_CALL_DISABLED_MESSAGE } from './btw'; @@ -34,9 +36,15 @@ export class SessionBtwService implements ISessionBtwService { kind: 'system_trigger', name: 'btw', }); + const reason = + child.accessor.get(IAgentToolApprovalService)?.formatDenyMessage( + TOOL_CALL_DISABLED_MESSAGE, + ) ?? TOOL_CALL_DISABLED_MESSAGE; child.accessor - .get(IAgentPermissionPolicyService) - ?.registerPolicy(new DenyAllPermissionPolicyService(TOOL_CALL_DISABLED_MESSAGE)); + .get(IAgentToolExecutorService) + ?.hooks.onBeforeExecuteTool.register('btw-deny-all', async (ctx) => { + ctx.decision = { block: true, reason }; + }); return child.id; } } diff --git a/packages/agent-core-v2/test/agent/goal/goal.test.ts b/packages/agent-core-v2/test/agent/goal/goal.test.ts index 6928d7e624..68e58c7954 100644 --- a/packages/agent-core-v2/test/agent/goal/goal.test.ts +++ b/packages/agent-core-v2/test/agent/goal/goal.test.ts @@ -4,7 +4,7 @@ * Wiring: real goal/wire services; loop is stubbed only for focused scheduling cases. * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/agent/goal/goal.test.ts`. */ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import type { TurnEndedEvent } from '#/agent/loop/turnEvents'; @@ -18,6 +18,10 @@ import { UpdateGoalTool, UpdateGoalToolInputSchema } from '#/agent/goal/tools/up import { IAgentLoopService, type AfterStepContext, type EnqueueReceipt, type Step, type Turn } from '#/agent/loop/loop'; import { MessageStepRequest } from '#/agent/loop/stepRequest'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentSwarmService } from '#/agent/swarm/swarm'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { PermissionMode, PermissionPolicyResult } from '#/agent/permissionPolicy/types'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService, type ToolExecutionResult, @@ -31,22 +35,37 @@ import { APIConnectionError, APIStatusError } from '#/kosong/contract/errors'; import type { ToolCall } from '#/kosong/contract/message'; import type { TokenUsage } from '#/kosong/contract/usage'; import { ErrorCodes, Error2, errorInfo, toKimiErrorPayload } from '#/errors'; -import type { ExecutableTool } from '#/tool/toolContract'; +import type { HookHandler } from '#/hooks'; +import type { ExecutableTool, RunnableToolExecution } from '#/tool/toolContract'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; import { InMemoryWireRecordPersistence, appService, agentService, - createTestAgent, + createTestAgent as createHarnessTestAgent, permissionModeServices, telemetryServices, - testAgent, wireRecordPersistenceServices, type TestAgentContext, type TestAgentOptions, + type TestAgentServiceOverride, } from '../../harness'; import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; import { stubLoopWithHooks, type StubLoop } from '../loop/stubs'; +import { stubAgentSwarm } from './stubs'; + +// The real AgentSwarmService registers its executor hook `before: 'permission'`, +// which the test-agent harness cannot satisfy (the permission gate transitively +// constructs the swarm service through the policy chain). Goal tests never +// exercise swarm behavior, so every test agent here stubs it out. +function createTestAgent( + ...inputs: readonly (TestAgentServiceOverride | TestAgentOptions)[] +): TestAgentContext { + return createHarnessTestAgent(agentService(IAgentSwarmService, stubAgentSwarm()), ...inputs); +} + +const testAgent = createTestAgent; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; type GoalRecord = WireRecord & { type: `goal.${string}` }; @@ -684,6 +703,121 @@ describe('AgentGoalService', () => { }); }); +describe('AgentGoalService goal-start review', () => { + interface ApprovalCall { + readonly result: Extract; + readonly origin: string; + } + + const goalStartDisplay: ToolInputDisplay = { + kind: 'goal_start', + objective: 'Ship feature X', + completionCriterion: undefined, + mode: 'manual', + }; + + let ctx: TestAgentContext | undefined; + let toolExecutor: IAgentToolExecutorService; + let approvalCalls: ApprovalCall[]; + let tailHook: Mock>; + + function approvalStub(): IAgentToolApprovalService { + return { + _serviceBrand: undefined, + resolvePermissionResolution: async () => undefined, + requestToolApproval: async (_context, result, origin) => { + approvalCalls.push({ result, origin }); + return undefined; + }, + formatDenyMessage: (message) => message, + formatApprovalRejectionMessage: () => 'rejected', + }; + } + + function setup(mode: PermissionMode): void { + approvalCalls = []; + ctx = createTestAgent( + permissionModeServices(mode), + agentService(IAgentToolApprovalService, approvalStub()), + ); + ctx.get(IAgentGoalService); + toolExecutor = ctx.get(IAgentToolExecutorService); + tailHook = vi.fn>(async (_hookCtx, next) => { + await next(); + }); + toolExecutor.hooks.onBeforeExecuteTool.register('test-tail', tailHook); + } + + afterEach(async () => { + await ctx?.dispose(); + }); + + function createGoalHookContext(display: ToolInputDisplay | undefined): ToolBeforeExecuteContext { + const toolCall: ToolCall = { + type: 'function', + id: 'call_create_goal', + name: 'CreateGoal', + arguments: JSON.stringify({ objective: 'Ship feature X' }), + }; + const execution: RunnableToolExecution = { + description: 'Creating a goal', + display, + approvalRule: 'CreateGoal', + execute: async () => ({ output: '' }), + }; + return { + turnId: 1, + signal: new AbortController().signal, + toolCall, + toolCalls: [toolCall], + args: { objective: 'Ship feature X' }, + execution, + }; + } + + it('routes a goal_start CreateGoal through toolApproval and applies the mode switch', async () => { + setup('manual'); + const hookCtx = createGoalHookContext(goalStartDisplay); + + await toolExecutor.hooks.onBeforeExecuteTool.run(hookCtx); + + expect(approvalCalls).toHaveLength(1); + expect(approvalCalls[0]!.origin).toBe('goal-start-review-ask'); + expect(approvalCalls[0]!.result.kind).toBe('ask'); + expect(hookCtx.decision).toBeUndefined(); + expect(tailHook).not.toHaveBeenCalled(); + + const resolved = approvalCalls[0]!.result.resolveApproval?.({ + decision: 'approved', + selectedLabel: 'yolo', + }); + expect(resolved).toBeUndefined(); + expect(ctx!.get(IAgentPermissionModeService).mode).toBe('yolo'); + }); + + it('lets CreateGoal through to the permission chain in auto mode', async () => { + setup('auto'); + const hookCtx = createGoalHookContext(goalStartDisplay); + + await toolExecutor.hooks.onBeforeExecuteTool.run(hookCtx); + + expect(approvalCalls).toHaveLength(0); + expect(tailHook).toHaveBeenCalledTimes(1); + expect(hookCtx.decision).toBeUndefined(); + }); + + it('lets CreateGoal through to the permission chain without a goal_start display', async () => { + setup('manual'); + const hookCtx = createGoalHookContext({ kind: 'generic', summary: 'Creating a goal' }); + + await toolExecutor.hooks.onBeforeExecuteTool.run(hookCtx); + + expect(approvalCalls).toHaveLength(0); + expect(tailHook).toHaveBeenCalledTimes(1); + expect(hookCtx.decision).toBeUndefined(); + }); +}); + describe('AgentGoalService core workflow hooks', () => { let ctx: TestAgentContext | undefined; let context: IAgentContextMemoryService; diff --git a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts index b3ec371333..3cf96af16a 100644 --- a/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts +++ b/packages/agent-core-v2/test/agent/goal/injection/goalInjection.test.ts @@ -6,12 +6,15 @@ import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory' import { IAgentGoalService } from '#/agent/goal/goal'; import { type AgentGoalService } from '#/agent/goal/goalService'; import { IAgentProfileService } from '#/agent/profile/profile'; +import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { InMemoryWireRecordPersistence, + agentService, createTestAgent, wireRecordPersistenceServices, type TestAgentContext, } from '../../../harness'; +import { stubAgentSwarm } from '../stubs'; type GoalServiceTestManager = IAgentGoalService & AgentGoalService; type InjectableContextInjector = IAgentContextInjectorService & { inject(): Promise }; @@ -55,7 +58,7 @@ describe('GoalInjection content', () => { let injector: InjectableContextInjector; beforeEach(() => { - ctx = createTestAgent(); + ctx = createTestAgent(agentService(IAgentSwarmService, stubAgentSwarm())); goals = ctx.get(IAgentGoalService) as GoalServiceTestManager; context = ctx.get(IAgentContextMemoryService); injector = ctx.get(IAgentContextInjectorService) as InjectableContextInjector; @@ -269,7 +272,10 @@ describe('GoalInjection integration', () => { beforeEach(() => { persistence = new InMemoryWireRecordPersistence(); - ctx = createTestAgent(wireRecordPersistenceServices(persistence)); + ctx = createTestAgent( + wireRecordPersistenceServices(persistence), + agentService(IAgentSwarmService, stubAgentSwarm()), + ); goals = ctx.get(IAgentGoalService) as GoalServiceTestManager; profile = ctx.get(IAgentProfileService); injector = ctx.get(IAgentContextInjectorService) as InjectableContextInjector; diff --git a/packages/agent-core-v2/test/agent/goal/stubs.ts b/packages/agent-core-v2/test/agent/goal/stubs.ts new file mode 100644 index 0000000000..0ef85e5a86 --- /dev/null +++ b/packages/agent-core-v2/test/agent/goal/stubs.ts @@ -0,0 +1,25 @@ +/** + * Shared stubs for goal tests. + */ + +import type { IAgentSwarmService } from '#/agent/swarm/swarm'; + +/** + * Inert stand-in for `IAgentSwarmService`. + * + * Goal tests never exercise swarm behavior, but the test-agent harness + * instantiates every contributed tool, and `AgentSwarmTool` injects the real + * `AgentSwarmService` — whose constructor registers an executor hook ordered + * `before: 'permission'` and therefore throws while the permission gate hook + * does not exist yet (the gate itself transitively constructs the swarm + * service through the policy chain). Stubbing the service keeps goal tests + * focused on goal wiring. + */ +export function stubAgentSwarm(): IAgentSwarmService { + return { + _serviceBrand: undefined, + isActive: false, + enter: () => undefined, + exit: () => undefined, + }; +} diff --git a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts index 3f24de9041..70ab5e0642 100644 --- a/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts +++ b/packages/agent-core-v2/test/agent/goal/tools/goal-tools.test.ts @@ -23,6 +23,7 @@ import { } from '#/agent/goal/tools/update-goal'; import { IAgentLoopService } from '#/agent/loop/loop'; import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { IAgentToolExecutorService, type ToolExecutionResult, @@ -38,6 +39,7 @@ import { type TestAgentContext, } from '../../../harness'; import { stubLoopWithHooks } from '../../loop/stubs'; +import { stubAgentSwarm } from '../stubs'; const signal = new AbortController().signal; @@ -54,6 +56,7 @@ describe('goal tools', () => { loopService = stubLoopWithHooks({ hasActiveTurn: true }); ctx = createTestAgent( agentService(IAgentLoopService, loopService), + agentService(IAgentSwarmService, stubAgentSwarm()), permissionModeServices('auto'), ); goals = ctx.get(IAgentGoalService); diff --git a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts index b59db0edbb..960fbe4a51 100644 --- a/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts +++ b/packages/agent-core-v2/test/agent/permissionGate/permissionGate.test.ts @@ -1,42 +1,28 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices } from '#/_base/di/test'; import type { TestInstantiationService } from '#/_base/di/test'; -import { - ISessionApprovalService, - type ApprovalRequest, - type ApprovalResponse, -} from '#/session/approval/approval'; -import { IHostEnvironment } from '#/os/interface/hostEnvironment'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import type { + AuthorizeToolExecutionResult, + ResolvedToolExecutionHookContext, + ToolBeforeExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; import { IAgentPermissionGate } from '#/agent/permissionGate/permissionGate'; import { AgentPermissionGate } from '#/agent/permissionGate/permissionGateService'; -import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import type { PermissionPolicyEvaluation } from '#/agent/permissionPolicy/permissionPolicy'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; +import type { PermissionMode, PermissionPolicyResolution } from '#/agent/permissionPolicy/types'; import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; -import { AgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicyService'; import { IAgentPermissionRulesService, - type PermissionApprovalResultRecord, type PermissionRule, } from '#/agent/permissionRules/permissionRules'; -import { IAgentPlanService } from '#/agent/plan/plan'; -import { IAgentProfileService, type ProfileData } from '#/agent/profile/profile'; -import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; -import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; import type { ToolCall } from '#/kosong/contract/message'; -import { IEventBus } from '#/app/event/eventBus'; -import { EventBusService } from '#/app/event/eventBusService'; -import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import { stubApprovalService } from '../../session/approval/stubs'; import { stubPermissionModeService } from '../permissionMode/stubs'; import { stubPermissionPolicyService } from '../permissionPolicy/stubs'; import { stubPermissionRulesService } from '../permissionRules/stubs'; @@ -46,8 +32,6 @@ import { stubToolExecutor } from '../loop/stubs'; function makeContext( toolName: string, args: Record = {}, - display?: ToolInputDisplay, - traceId?: string, ): ResolvedToolExecutionHookContext { const toolCall: ToolCall = { type: 'function', @@ -58,43 +42,44 @@ function makeContext( return { turnId: 1, signal: new AbortController().signal, - trace: { traceId }, toolCall, toolCalls: [toolCall], args, execution: { description: `Approve ${toolName}`, approvalRule: toolName, - display, execute: () => Promise.resolve({ output: '' }), }, }; } -function planReviewDisplay(): ToolInputDisplay { - return { - kind: 'plan_review', - plan: '# Plan', - path: '/tmp/kimi-plan.md', - }; -} - describe('AgentPermissionGate', () => { let disposables: DisposableStore; let ix: TestInstantiationService; let mode: PermissionMode; let rules: readonly PermissionRule[]; let policyResult: PermissionPolicyEvaluation | undefined; - let approvalResponse: ApprovalResponse; - let eventBus: IEventBus; + let records: TelemetryRecord[]; + let executor: ReturnType; + let resolvePermissionResolution: ReturnType< + typeof vi.fn + >; beforeEach(() => { disposables = new DisposableStore(); - eventBus = disposables.add(new EventBusService()); mode = 'auto'; rules = []; policyResult = undefined; - approvalResponse = { decision: 'approved' }; + records = []; + executor = stubToolExecutor(); + resolvePermissionResolution = vi.fn(async () => undefined); + const toolApproval: IAgentToolApprovalService = { + _serviceBrand: undefined, + resolvePermissionResolution, + requestToolApproval: vi.fn(async () => undefined), + formatDenyMessage: (message) => message, + formatApprovalRejectionMessage: () => '', + }; ix = createServices(disposables, { additionalServices: (reg) => { reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); @@ -103,42 +88,12 @@ describe('AgentPermissionGate', () => { IAgentPermissionPolicyService, stubPermissionPolicyService(() => policyResult), ); - reg.defineInstance(IEventBus, eventBus); - reg.definePartialInstance(ITelemetryService, { track: () => {}, track2: () => {} }); - reg.defineInstance(ISessionApprovalService, stubApprovalService(() => approvalResponse)); - reg.defineInstance(ISessionContext, makeSessionContext({ - sessionId: 'test-session', - workspaceId: 'test-workspace', - sessionDir: '/tmp/test-session', - sessionScope: 'sessions/test-workspace/test-session', - metaScope: 'sessions/test-workspace/test-session/session-meta', - cwd: '/tmp/test-session', - })); - reg.definePartialInstance(IAgentPlanService, { - status: async () => null, - exit: () => {}, - }); - reg.definePartialInstance(IAgentSwarmService, { - isActive: false, - }); - reg.definePartialInstance(IHostEnvironment, { - pathClass: 'posix', - }); - reg.definePartialInstance(ISessionWorkspaceContext, { - workDir: '/workspace', - additionalDirs: [], - }); - reg.defineInstance(IAgentToolExecutorService, stubToolExecutor()); - reg.define(IEventBus, EventBusService); - reg.defineInstance(IAgentScopeContext, { - _serviceBrand: undefined, - agentId: 'main', - scope: (subKey?: string): string => (subKey ? `main/${subKey}` : 'main'), - }); - reg.definePartialInstance(IAgentProfileService, { - data: () => ({ cwd: '/workspace' }) as ProfileData, - }); + reg.defineInstance(IAgentToolApprovalService, toolApproval); + reg.defineInstance(ITelemetryService, recordingTelemetry(records)); + reg.defineInstance(IAgentToolExecutorService, executor); + reg.define(IAgentPermissionGate, AgentPermissionGate); }, + strict: true, }); }); afterEach(() => { @@ -146,450 +101,124 @@ describe('AgentPermissionGate', () => { }); function make(): IAgentPermissionGate { - ix.set(IAgentPermissionGate, new SyncDescriptor(AgentPermissionGate)); return ix.get(IAgentPermissionGate); } - function useRealPolicyService(): void { - ix.set(IAgentPermissionPolicyService, new SyncDescriptor(AgentPermissionPolicyService)); - } - - function setApprovalRequest( - request: (approval: ApprovalRequest) => Promise, - ): ReturnType Promise>> { - const requestSpy = vi.fn(request); - ix.set(ISessionApprovalService, { - _serviceBrand: undefined, - request: requestSpy, - enqueue: (approval) => ({ ...approval, id: approval.id ?? 'approval-1' }), - decide: () => {}, - listPending: () => [], - }); - return requestSpy; - } - - function recordTelemetry(): TelemetryRecord[] { - const records: TelemetryRecord[] = []; - ix.set(ITelemetryService, recordingTelemetry(records)); - return records; - } - - it('returns undefined when no policy evaluates', async () => { + it('returns undefined without consulting approvals when no policy evaluates', async () => { const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toBeUndefined(); - }); - it('maps an approve decision to undefined', async () => { - policyResult = { policyName: 'p', result: { kind: 'approve' } }; - const svc = make(); expect(await svc.authorize(makeContext('bash'))).toBeUndefined(); + expect(resolvePermissionResolution).not.toHaveBeenCalled(); + expect(records).toEqual([]); }); - it('passes executionMetadata through on approve', async () => { - const executionMetadata = { marker: true }; - policyResult = { - policyName: 'p', - result: { kind: 'approve', executionMetadata }, - }; + it('forwards the policy resolution to the approval service and returns its result', async () => { + const resolution: PermissionPolicyResolution = { kind: 'deny', message: 'nope' }; + policyResult = { policyName: 'user-configured-deny', result: resolution }; + const blocked: AuthorizeToolExecutionResult = { block: true, reason: 'nope' }; + resolvePermissionResolution.mockResolvedValue(blocked); const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toEqual({ executionMetadata }); - }); + const ctx = makeContext('bash'); - it('maps a deny decision to a block with the policy message', async () => { - policyResult = { policyName: 'p', result: { kind: 'deny', message: 'nope' } }; - const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toEqual({ - block: true, - reason: 'nope', - }); - }); - - it('adds subagent retry guidance to policy deny messages', async () => { - policyResult = { policyName: 'p', result: { kind: 'deny', message: 'nope' } }; - ix.set(IAgentScopeContext, { - _serviceBrand: undefined, - agentId: 'sub-1', - scope: (subKey?: string): string => (subKey ? `sub-1/${subKey}` : 'sub-1'), - }); - const svc = make(); - const retryGuidance = - "Try a different approach — don't retry the same call, don't attempt to bypass the restriction."; - - expect(await svc.authorize(makeContext('bash'))).toEqual({ - block: true, - reason: `nope ${retryGuidance}`, - }); - }); - - it('uses a default reason when a deny has no message', async () => { - policyResult = { policyName: 'p', result: { kind: 'deny' } }; - const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toEqual({ - block: true, - reason: 'Tool "bash" was denied by permission policy.', - }); - }); - - it('maps an approved ask to undefined', async () => { - policyResult = { policyName: 'p', result: { kind: 'ask' } }; - approvalResponse = { decision: 'approved' }; - const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toBeUndefined(); + expect(await svc.authorize(ctx)).toBe(blocked); + expect(resolvePermissionResolution).toHaveBeenCalledWith( + resolution, + ctx, + 'user-configured-deny', + ); }); - it('maps a rejected ask to a block', async () => { - policyResult = { policyName: 'p', result: { kind: 'ask' } }; - approvalResponse = { decision: 'rejected' }; + it('passes an approve result with executionMetadata straight through', async () => { + const executionMetadata = { marker: true }; + policyResult = { policyName: 'p', result: { kind: 'approve', executionMetadata } }; + resolvePermissionResolution.mockResolvedValue({ executionMetadata }); const svc = make(); - expect(await svc.authorize(makeContext('bash'))).toEqual({ - block: true, - reason: 'Tool "bash" was not run because the user rejected the approval request.', - }); - }); - it('data() reflects the mode and rules services', () => { - mode = 'yolo'; - rules = [{ decision: 'allow', scope: 'user', pattern: 'Bash(*)' }]; - const svc = make(); - expect(svc.data()).toEqual({ mode: 'yolo', rules }); + expect(await svc.authorize(makeContext('bash'))).toEqual({ executionMetadata }); }); - it.each([ - ['Read', { path: '/workspace/notes.md' }], - ['Grep', { pattern: 'TODO', path: '/workspace' }], - ['Glob', { pattern: '**/*.ts', path: '/workspace' }], - ['ReadMediaFile', { path: '/workspace/image.png' }], - ['SetTodoList', { items: [] }], - ['TodoList', {}], - ['TaskList', {}], - ['TaskOutput', { task_id: 'task_1' }], - ['CronList', {}], - ['WebSearch', { query: 'kimi code' }], - ['FetchURL', { url: 'https://example.com' }], - ['Agent', { prompt: 'review this' }], - ['AskUserQuestion', { questions: [] }], - ['Skill', { name: 'test-skill' }], - ] as const)( - 'does not request approval for default-approved %s in manual mode', - async (toolName, args) => { - mode = 'manual'; - useRealPolicyService(); - const request = setApprovalRequest(async () => ({ decision: 'approved' })); - const svc = make(); - - await expect(svc.authorize(makeContext(toolName, args))).resolves.toBeUndefined(); - - expect(request).not.toHaveBeenCalled(); - }, - ); - - it.each([ - ['Bash', { command: 'printf first', timeout: 60 }], - ['Write', { path: '/workspace/a.ts', content: 'x' }], - ['Edit', { path: '/workspace/a.ts', old_string: 'a', new_string: 'b' }], - ['Custom', { value: 1 }], - ] as const)( - 'requests approval for non-default %s in manual mode', - async (toolName, args) => { - mode = 'manual'; - useRealPolicyService(); - const request = setApprovalRequest(async () => ({ decision: 'approved' })); - const svc = make(); - - await expect(svc.authorize(makeContext(toolName, args))).resolves.toBeUndefined(); - - expect(request).toHaveBeenCalledTimes(1); - }, - ); - - it('keeps auto-mode AskUserQuestion deny above default approval', async () => { - mode = 'auto'; - useRealPolicyService(); - const request = setApprovalRequest(async () => ({ decision: 'approved' })); - const records = recordTelemetry(); + it('tracks the policy decision with the reason payload', async () => { + policyResult = { + policyName: 'user-configured-deny', + result: { + kind: 'deny', + message: 'nope', + reason: { matched_rule: 'Bash', match_strategy: 'literal' }, + }, + }; const svc = make(); - await expect( - svc.authorize(makeContext('AskUserQuestion', { questions: [] })), - ).resolves.toMatchObject({ - block: true, - reason: expect.stringContaining('AskUserQuestion is disabled'), - }); + await svc.authorize(makeContext('Bash')); - expect(request).not.toHaveBeenCalled(); expect(records).toContainEqual({ event: 'permission_policy_decision', - properties: expect.objectContaining({ + properties: { turn_id: 1, - tool_call_id: 'call-AskUserQuestion', - policy_name: 'auto-mode-ask-user-question-deny', - tool_name: 'AskUserQuestion', + tool_call_id: 'call-Bash', + policy_name: 'user-configured-deny', + tool_name: 'Bash', + permission_mode: 'auto', decision: 'deny', - }), - }); - }); - - it('turns approved session-scoped responses into an agent-local runtime rule cache', async () => { - mode = 'manual'; - const sessionApprovalRulePatterns: string[] = []; - const recorded: PermissionApprovalResultRecord[] = []; - ix.set(IAgentPermissionRulesService, mutablePermissionRulesService({ - rules: () => [], - sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, - record: (record) => { - recorded.push(record); - if (record.sessionApprovalRule !== undefined) { - sessionApprovalRulePatterns.push(record.sessionApprovalRule); - } + matched_rule: 'Bash', + match_strategy: 'literal', }, - })); - useRealPolicyService(); - const request = setApprovalRequest(async () => ({ - decision: 'approved', - scope: 'session', - selectedLabel: 'Approve for this session', - })); - const records = recordTelemetry(); - const svc = make(); - - await expect(svc.authorize(makeContext('Custom', { query: 'first' }))).resolves - .toBeUndefined(); - await expect(svc.authorize(makeContext('Custom', { query: 'second' }))).resolves - .toBeUndefined(); - - expect(request).toHaveBeenCalledTimes(1); - expect(recorded[0]).toMatchObject({ - toolName: 'Custom', - sessionApprovalRule: 'Custom', - result: { decision: 'approved', scope: 'session' }, - }); - expect(sessionApprovalRulePatterns).toEqual(['Custom']); - expect(records).toContainEqual({ - event: 'permission_policy_decision', - properties: expect.objectContaining({ - policy_name: 'session-approval-history', - tool_name: 'Custom', - decision: 'approve', - }), }); }); - it('keeps approved-once responses one-shot', async () => { - mode = 'manual'; - const recorded: PermissionApprovalResultRecord[] = []; - ix.set(IAgentPermissionRulesService, mutablePermissionRulesService({ - rules: () => [], - sessionApprovalRulePatterns: () => [], - record: (record) => recorded.push(record), - })); - useRealPolicyService(); - const request = setApprovalRequest(async () => ({ decision: 'approved' })); - const svc = make(); + it('writes the decision into the hook context and stops the chain on block', async () => { + const blocked: AuthorizeToolExecutionResult = { block: true, reason: 'nope' }; + policyResult = { policyName: 'p', result: { kind: 'deny', message: 'nope' } }; + resolvePermissionResolution.mockResolvedValue(blocked); + make(); + const ctx: ToolBeforeExecuteContext = makeContext('bash'); + const terminal = vi.fn(async () => {}); - await expect(svc.authorize(makeContext('Custom', { query: 'first' }))).resolves - .toBeUndefined(); - await expect(svc.authorize(makeContext('Custom', { query: 'second' }))).resolves - .toBeUndefined(); + await executor.hooks.onBeforeExecuteTool.run(ctx, terminal); - expect(request).toHaveBeenCalledTimes(2); - expect(recorded).toHaveLength(2); - expect(recorded.every((record) => record.sessionApprovalRule === undefined)).toBe(true); + expect(ctx.decision).toBe(blocked); + expect(terminal).not.toHaveBeenCalled(); }); - it('publishes approval events while waiting for user approval', async () => { - const permissionRequest = vi.fn(); - const permissionResult = vi.fn(); + it('stops the chain on a synthetic result', async () => { + const synthetic: AuthorizeToolExecutionResult = { + syntheticResult: { output: 'Plan review handled.' }, + }; policyResult = { policyName: 'p', result: { kind: 'ask' } }; - approvalResponse = { decision: 'approved', selectedLabel: 'Approve once' }; - const request = setApprovalRequest(async () => approvalResponse); - const svc = make(); - const eventBus = ix.get(IEventBus); - disposables.add( - eventBus.subscribe('permission.approval.requested', permissionRequest), - ); - disposables.add( - eventBus.subscribe('permission.approval.resolved', permissionResult), - ); - - await expect(svc.authorize(makeContext('Bash', { command: 'printf first' }))).resolves - .toBeUndefined(); - - expect(request).toHaveBeenCalledTimes(1); - expect(permissionRequest).toHaveBeenCalledWith({ - type: 'permission.approval.requested', - sessionId: 'test-session', - agentId: 'main', - turnId: 1, - toolCallId: 'call-Bash', - toolName: 'Bash', - action: 'Approve Bash', - toolInput: { command: 'printf first' }, - display: { - kind: 'generic', - summary: 'Approve Bash', - detail: { command: 'printf first' }, - }, - }); - expect(permissionResult).toHaveBeenCalledWith({ - type: 'permission.approval.resolved', - sessionId: 'test-session', - agentId: 'main', - turnId: 1, - toolCallId: 'call-Bash', - toolName: 'Bash', - action: 'Approve Bash', - toolInput: { command: 'printf first' }, - display: { - kind: 'generic', - summary: 'Approve Bash', - detail: { command: 'printf first' }, - }, - decision: 'approved', - selectedLabel: 'Approve once', - }); - }); + resolvePermissionResolution.mockResolvedValue(synthetic); + make(); + const ctx: ToolBeforeExecuteContext = makeContext('ExitPlanMode'); + const terminal = vi.fn(async () => {}); - it('tracks cancelled approval requests', async () => { - mode = 'manual'; - policyResult = { policyName: 'fallback-ask', result: { kind: 'ask' } }; - approvalResponse = { decision: 'cancelled', feedback: 'request closed' }; - const records = recordTelemetry(); - const svc = make(); - - await expect(svc.authorize(makeContext('Bash'))).resolves.toMatchObject({ - block: true, - reason: expect.stringContaining('approval request was cancelled'), - }); + await executor.hooks.onBeforeExecuteTool.run(ctx, terminal); - expect(records).toContainEqual({ - event: 'permission_approval_result', - properties: expect.objectContaining({ - turn_id: 1, - tool_call_id: 'call-Bash', - policy_name: 'fallback-ask', - tool_name: 'Bash', - permission_mode: 'manual', - result: 'cancelled', - has_feedback: true, - session_cache_written: false, - }), - }); + expect(ctx.decision).toBe(synthetic); + expect(terminal).not.toHaveBeenCalled(); }); it.each([ - ['rejected', { decision: 'rejected' }, 'rejected', false], - ['cancelled', { decision: 'cancelled' }, 'cancelled', false], - [ - 'revise feedback', - { decision: 'rejected', selectedLabel: 'Revise', feedback: 'Add verification.' }, - 'rejected', - true, - ], - ] as const)( - 'tracks plan review approval telemetry for %s', - async (_name, response, expectedResult, expectedHasFeedback) => { - mode = 'manual'; - policyResult = { - policyName: 'exit-plan-mode-review-ask', - result: { - kind: 'ask', - resolveApproval: () => ({ - kind: 'result', - syntheticResult: { output: 'Plan review handled.' }, - }), - }, - }; - approvalResponse = response; - const records = recordTelemetry(); - const svc = make(); - - await svc.authorize(makeContext('ExitPlanMode', {}, planReviewDisplay())); - - expect(records).toContainEqual({ - event: 'permission_approval_result', - properties: expect.objectContaining({ - policy_name: 'exit-plan-mode-review-ask', - tool_name: 'ExitPlanMode', - permission_mode: 'manual', - result: expectedResult, - approval_surface: 'plan_review', - duration_ms: expect.any(Number), - session_cache_written: false, - has_feedback: expectedHasFeedback, - }), - }); - }, - ); - - it('tracks approval transport errors before rethrowing', async () => { - policyResult = { policyName: 'exit-plan-mode-review-ask', result: { kind: 'ask' } }; - const error = new Error('approval transport closed'); - ix.set(ISessionApprovalService, { - _serviceBrand: undefined, - request: vi.fn(async () => { - throw error; - }), - enqueue: (approval) => ({ ...approval, id: approval.id ?? 'approval-1' }), - decide: () => {}, - listPending: () => [], - }); - const records = recordTelemetry(); - const svc = make(); + ['no decision', undefined], + ['approve with executionMetadata', { executionMetadata: { marker: true } }], + ] as const)('continues the chain on %s', async (_name, result) => { + policyResult = { policyName: 'p', result: { kind: 'approve' } }; + resolvePermissionResolution.mockResolvedValue(result); + make(); + const ctx: ToolBeforeExecuteContext = makeContext('bash'); + const terminal = vi.fn(async () => {}); - await expect(svc.authorize(makeContext('ExitPlanMode'))).rejects.toThrow( - 'approval transport closed', - ); + await executor.hooks.onBeforeExecuteTool.run(ctx, terminal); - expect(records).toContainEqual({ - event: 'permission_approval_result', - properties: expect.objectContaining({ - turn_id: 1, - tool_call_id: 'call-ExitPlanMode', - policy_name: 'exit-plan-mode-review-ask', - tool_name: 'ExitPlanMode', - result: 'error', - }), - }); + if (result === undefined) { + expect(ctx.decision).toBeUndefined(); + } else { + expect(ctx.decision).toBe(result); + } + expect(terminal).toHaveBeenCalledTimes(1); }); - it('merges the request trace id into approval result telemetry', async () => { - mode = 'manual'; - policyResult = { policyName: 'p', result: { kind: 'ask' } }; - approvalResponse = { decision: 'approved' }; - const records = recordTelemetry(); + it('data() reflects the mode and rules services', () => { + mode = 'yolo'; + rules = [{ decision: 'allow', scope: 'user', pattern: 'Bash(*)' }]; const svc = make(); - - await svc.authorize(makeContext('bash', {}, undefined, 'trace-approval-1')); - - expect(records).toContainEqual({ - event: 'permission_approval_result', - properties: expect.objectContaining({ - tool_name: 'bash', - result: 'approved', - trace_id: 'trace-approval-1', - }), - }); + expect(svc.data()).toEqual({ mode: 'yolo', rules }); }); }); - -interface MutableRulesOptions { - readonly rules: () => readonly PermissionRule[]; - readonly sessionApprovalRulePatterns: () => readonly string[]; - readonly record?: (record: PermissionApprovalResultRecord) => void; -} - -function mutablePermissionRulesService( - options: MutableRulesOptions, -): IAgentPermissionRulesService { - return { - _serviceBrand: undefined, - get rules() { - return options.rules(); - }, - get sessionApprovalRulePatterns() { - return options.sessionApprovalRulePatterns(); - }, - addRules: () => {}, - recordApprovalResult: (record) => options.record?.(record), - }; -} diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts index 2c0e5b28c6..47e133f418 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/permissionPolicyService.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import type { ToolCall } from '#/kosong/contract/message'; import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DisposableStore } from '#/_base/di/lifecycle'; import { createServices, type TestInstantiationService } from '#/_base/di/test'; @@ -17,9 +17,6 @@ import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/tool import { IHostEnvironment, type IHostEnvironment as HostEnvironmentService } from '#/os/interface/hostEnvironment'; import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; import { IAgentPermissionPolicyService, type PermissionPolicyEvaluation } from '#/agent/permissionPolicy/permissionPolicy'; -import { DenyAllPermissionPolicyService } from '#/agent/permissionPolicy/policies/deny-all'; -import { AgentSwarmExclusiveDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/agent-swarm-exclusive-deny'; -import { SwarmModeAgentSwarmApprovePermissionPolicyService } from '#/agent/permissionPolicy/policies/swarm-mode-agent-swarm-approve'; import type { PermissionMode } from '#/agent/permissionPolicy/types'; import { AgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicyService'; import { @@ -27,9 +24,7 @@ import { type IAgentPermissionRulesService as PermissionRulesServiceContract, type PermissionRule, } from '#/agent/permissionRules/permissionRules'; -import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ToolAccesses, type ToolAccesses as ToolAccessList } from '#/tool/toolContract'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -45,8 +40,6 @@ describe('AgentPermissionPolicyService chain', () => { let mode: PermissionMode; let rules: PermissionRule[]; let sessionApprovalRulePatterns: string[]; - let plan: PlanData; - let swarmActive: boolean; let workspace: ReturnType; beforeEach(() => { @@ -54,8 +47,6 @@ describe('AgentPermissionPolicyService chain', () => { mode = 'manual'; rules = []; sessionApprovalRulePatterns = []; - plan = null; - swarmActive = false; workspace = workspaceStub('/workspace'); ix = createServices(disposables, { additionalServices: (reg) => { @@ -70,10 +61,6 @@ describe('AgentPermissionPolicyService chain', () => { })); reg.defineInstance(ISessionWorkspaceContext, workspace); reg.defineInstance(IHostEnvironment, kaosStub()); - reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => { - plan = null; - })); - reg.definePartialInstance(IAgentSwarmService, swarmServiceStub(() => swarmActive)); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, @@ -96,21 +83,6 @@ describe('AgentPermissionPolicyService chain', () => { return svc.evaluate(policyContext(input)); } - it('lets a registered deny-all policy take precedence over approvals', async () => { - const svc = service(); - const registration = svc.registerPolicy(new DenyAllPermissionPolicyService('tools disabled')); - - await expect(evaluate({ toolName: 'Read', args: { path: 'src/a.ts' } })).resolves.toMatchObject({ - policyName: 'deny-all', - result: { kind: 'deny', message: 'tools disabled' }, - }); - - registration.dispose(); - await expect(evaluate({ toolName: 'Read', args: { path: 'src/a.ts' } })).resolves.not.toMatchObject({ - policyName: 'deny-all', - }); - }); - it('keeps auto-mode AskUserQuestion deny above default approval', async () => { mode = 'auto'; @@ -123,33 +95,6 @@ describe('AgentPermissionPolicyService chain', () => { }); }); - it('denies invalid AgentSwarm batches before auto-mode approval', async () => { - mode = 'auto'; - const agentSwarmArgs = { - description: 'Review files', - prompt_template: 'Review {{item}}', - items: ['src/a.ts', 'src/b.ts'], - }; - const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', agentSwarmArgs); - const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' }); - - await expect(evaluate({ - toolName: 'AgentSwarm', - args: agentSwarmArgs, - toolCall: agentSwarmCall, - toolCalls: [agentSwarmCall, readCall], - })).resolves.toMatchObject({ - policyName: 'agent-swarm-exclusive-deny', - result: { - kind: 'deny', - reason: { - agent_swarm_tool_calls: 1, - tool_calls: 2, - }, - }, - }); - }); - it('applies deny rules before yolo-mode approval', async () => { mode = 'yolo'; rules.push({ @@ -216,197 +161,12 @@ describe('AgentPermissionPolicyService chain', () => { }, }); }); -}); -describe('AgentPermissionPolicyService plan-mode policies', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let mode: PermissionMode; - let sessionApprovalRulePatterns: string[]; - let plan: PlanData; - - beforeEach(() => { - disposables = new DisposableStore(); - mode = 'manual'; - sessionApprovalRulePatterns = []; - plan = null; - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); - reg.defineInstance( - IAgentScopeContext, - makeAgentScopeContext({ agentId: 'main', agentScope: '' }), - ); - reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub({ - sessionApprovalRulePatterns: () => sessionApprovalRulePatterns, - })); - reg.defineInstance(ISessionWorkspaceContext, workspaceStub('/workspace')); - reg.defineInstance(IHostEnvironment, kaosStub()); - reg.definePartialInstance(IAgentPlanService, planServiceStub(() => plan, () => { - plan = null; - })); - reg.definePartialInstance(IAgentSwarmService, swarmServiceStub(() => false)); - reg.defineInstance(ITelemetryService, recordingTelemetry([])); - reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); - }, - strict: true, - }); - }); - - afterEach(() => { - disposables.dispose(); - }); - - async function evaluate( - input: PolicyContextInput, - ): Promise { - const svc = ix.get(IAgentPermissionPolicyService); - return svc.evaluate(policyContext(input)); - } - - it('approves EnterPlanMode in manual mode', async () => { - await expect(evaluate({ toolName: 'EnterPlanMode', args: {} })).resolves.toMatchObject({ - policyName: 'plan-mode-tool-approve', - result: { kind: 'approve' }, - }); - }); - - it.each(['Write', 'Edit'] as const)( - 'approves %s when it only writes the active plan file', + it.each(['AgentSwarm', 'EnterPlanMode', 'ExitPlanMode', 'CreateGoal'] as const)( + 'approves %s through the default tool allowlist in manual mode', async (toolName) => { - const planFilePath = '/workspace/.kimi/plans/current.md'; - plan = planData(planFilePath); - await expect(evaluate({ - toolName, - args: toolName === 'Write' - ? { path: planFilePath, content: '# Plan' } - : { path: planFilePath, old_string: '# Draft', new_string: '# Plan' }, - accesses: toolName === 'Write' - ? ToolAccesses.writeFile(planFilePath) - : ToolAccesses.readWriteFile(planFilePath), - })).resolves.toMatchObject({ - policyName: 'plan-mode-tool-approve', - result: { kind: 'approve' }, - }); - }, - ); - - it('denies active plan-mode writes that have no file write access', async () => { - const planFilePath = '/workspace/.kimi/plans/current.md'; - plan = planData(planFilePath); - - await expect(evaluate({ - toolName: 'Write', - args: { path: planFilePath, content: '# Plan' }, - accesses: ToolAccesses.none(), - })).resolves.toMatchObject({ - policyName: 'plan-mode-guard-deny', - result: { - kind: 'deny', - message: expect.stringContaining('Plan mode is active'), - }, - }); - }); - - it('approves ExitPlanMode directly when plan mode is inactive', async () => { - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: planReviewDisplay({ plan: '# Plan' }), - })).resolves.toMatchObject({ - policyName: 'plan-mode-tool-approve', - result: { kind: 'approve' }, - }); - }); - - it('approves ExitPlanMode while active when there is no plan review display', async () => { - plan = planData('/tmp/plan.md'); - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: { kind: 'generic', summary: 'exit', detail: {} }, - })).resolves.toMatchObject({ - policyName: 'plan-mode-tool-approve', - result: { kind: 'approve' }, - }); - }); - - it('approves ExitPlanMode while active when the plan review is blank', async () => { - plan = planData('/tmp/plan.md'); - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: planReviewDisplay({ plan: ' \n\t' }), - })).resolves.toMatchObject({ - policyName: 'plan-mode-tool-approve', - result: { kind: 'approve' }, - }); - }); - - it('defers non-empty plan reviews to the review approval policy in manual mode', async () => { - plan = planData('/tmp/plan.md'); - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: planReviewDisplay({ plan: '# Plan' }), - })).resolves.toMatchObject({ - policyName: 'exit-plan-mode-review-ask', - result: { kind: 'ask' }, - }); - }); - - it('requests plan-review approval in yolo mode', async () => { - mode = 'yolo'; - plan = planData('/tmp/plan.md'); - - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: planReviewDisplay({ plan: '# Plan' }), - })).resolves.toMatchObject({ - policyName: 'exit-plan-mode-review-ask', - result: { kind: 'ask' }, - }); - }); - - it('reuses session approval for ExitPlanMode without re-prompting plan review', async () => { - sessionApprovalRulePatterns.push('ExitPlanMode'); - plan = planData('/tmp/plan.md'); - - await expect(evaluate({ - toolName: 'ExitPlanMode', - args: {}, - display: planReviewDisplay({ plan: '# Updated Plan' }), - })).resolves.toMatchObject({ - policyName: 'session-approval-history', - result: { kind: 'approve' }, - }); - }); - - it('uses ordinary Bash approval in manual plan mode', async () => { - plan = planData('/tmp/plan.md'); - await expect(evaluate({ - toolName: 'Bash', - args: { command: 'ls -la', timeout: 60 }, - })).resolves.toMatchObject({ - policyName: 'fallback-ask', - result: { kind: 'ask' }, - }); - }); - - it.each([ - ['auto', 'auto-mode-approve'], - ['yolo', 'yolo-mode-approve'], - ] as const)( - 'defers Bash to ordinary %s permission behavior in plan mode', - async (nextMode, policyName) => { - mode = nextMode; - plan = planData('/tmp/plan.md'); - await expect(evaluate({ - toolName: 'Bash', - args: { command: 'rm generated.txt', timeout: 60 }, - })).resolves.toMatchObject({ - policyName, + await expect(evaluate({ toolName, args: {} })).resolves.toMatchObject({ + policyName: 'default-tool-approve', result: { kind: 'approve' }, }); }, @@ -438,8 +198,6 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { reg.definePartialInstance(IAgentPermissionRulesService, permissionRulesStub()); reg.defineInstance(ISessionWorkspaceContext, workspace); reg.defineInstance(IHostEnvironment, kaosStub()); - reg.definePartialInstance(IAgentPlanService, planServiceStub(() => null)); - reg.definePartialInstance(IAgentSwarmService, swarmServiceStub(() => false)); reg.defineInstance(ITelemetryService, recordingTelemetry([])); reg.define(IAgentPermissionPolicyService, AgentPermissionPolicyService); }, @@ -569,117 +327,6 @@ describe('AgentPermissionPolicyService git cwd write approval', () => { }); }); -describe('AgentSwarm permission policies', () => { - const agentSwarmArgs = { - description: 'Review files', - prompt_template: 'Review {{item}}', - items: ['src/a.ts', 'src/b.ts'], - }; - - it('approves only AgentSwarm when swarm mode is active', () => { - let swarmActive = false; - const swarm = { - get isActive() { - return swarmActive; - }, - } as IAgentSwarmService; - const policy = new SwarmModeAgentSwarmApprovePermissionPolicyService(swarm); - - expect( - policy.evaluate(policyContext({ toolName: 'AgentSwarm', args: agentSwarmArgs })), - ).toBeUndefined(); - swarmActive = true; - expect( - policy.evaluate(policyContext({ toolName: 'AgentSwarm', args: agentSwarmArgs })), - ).toEqual({ kind: 'approve' }); - expect(policy.evaluate(policyContext({ toolName: 'Agent', args: {} }))).toBeUndefined(); - }); - - it('denies AgentSwarm mixed with other tool calls in the same response', () => { - const policy = new AgentSwarmExclusiveDenyPermissionPolicyService(); - const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', agentSwarmArgs); - const readCall = toolCall('call_read', 'Read', { path: 'src/a.ts' }); - - expect( - policy.evaluate( - policyContext({ - toolName: 'AgentSwarm', - args: agentSwarmArgs, - toolCall: agentSwarmCall, - toolCalls: [agentSwarmCall, readCall], - }), - ), - ).toMatchObject({ - kind: 'deny', - message: expect.stringContaining('AgentSwarm must be the only tool call'), - reason: { - agent_swarm_tool_calls: 1, - tool_calls: 2, - }, - }); - expect( - policy.evaluate( - policyContext({ - toolName: 'Read', - args: { path: 'src/a.ts' }, - toolCall: readCall, - toolCalls: [agentSwarmCall, readCall], - }), - ), - ).toMatchObject({ kind: 'deny' }); - }); - - it('denies multiple AgentSwarm calls with one-at-a-time guidance', () => { - const policy = new AgentSwarmExclusiveDenyPermissionPolicyService(); - const first = toolCall('call_agent_swarm_1', 'AgentSwarm', agentSwarmArgs); - const second = toolCall('call_agent_swarm_2', 'AgentSwarm', { - description: 'Review tests', - prompt_template: 'Review {{item}}', - items: ['test/a.ts', 'test/b.ts'], - }); - - const result = policy.evaluate( - policyContext({ - toolName: 'AgentSwarm', - args: agentSwarmArgs, - toolCall: first, - toolCalls: [first, second], - }), - ); - - expect(result).toMatchObject({ - kind: 'deny', - message: expect.stringContaining('Multiple AgentSwarm calls are not forbidden'), - reason: { - agent_swarm_tool_calls: 2, - tool_calls: 2, - }, - }); - expect(result).toMatchObject({ - message: expect.stringContaining('call one AgentSwarm, wait for its result'), - }); - expect(result).toMatchObject({ - message: expect.stringContaining('merge the work into a single AgentSwarm'), - }); - }); - - it('allows a single AgentSwarm call for later permission policies', () => { - const policy = new AgentSwarmExclusiveDenyPermissionPolicyService(); - const agentSwarmCall = toolCall('call_agent_swarm', 'AgentSwarm', agentSwarmArgs); - - expect( - policy.evaluate( - policyContext({ - toolName: 'AgentSwarm', - args: agentSwarmArgs, - toolCall: agentSwarmCall, - toolCalls: [agentSwarmCall], - }), - ), - ).toBeUndefined(); - }); -}); - interface MutablePermissionRulesStubOptions { readonly rules?: () => readonly PermissionRule[]; readonly sessionApprovalRulePatterns?: () => readonly string[]; @@ -706,26 +353,21 @@ interface PolicyContextInput { readonly id?: string; readonly toolName: string; readonly args: Record; - readonly toolCall?: ToolCall; - readonly toolCalls?: readonly ToolCall[]; - readonly display?: ToolInputDisplay; readonly accesses?: ToolAccessList; } function policyContext(input: PolicyContextInput): ResolvedToolExecutionHookContext { - const toolCall = - input.toolCall ?? - toolCallFor(input.id ?? `call_${input.toolName}`, input.toolName, input.args); + const toolCall = toolCallFor(input.id ?? `call_${input.toolName}`, input.toolName, input.args); const subject = ruleSubject(input.toolName, input.args); return { turnId: 0, signal, toolCall, - toolCalls: input.toolCalls ?? [toolCall], + toolCalls: [toolCall], args: input.args, execution: { description: description(input.toolName), - display: input.display ?? display(input.toolName, input.args), + display: display(input.toolName, input.args), accesses: input.accesses ?? accesses(input.toolName, input.args), approvalRule: subject === undefined ? input.toolName : literalRulePattern(input.toolName, subject), @@ -747,10 +389,6 @@ function toolCallFor(id: string, name: string, args: Record): T }; } -function toolCall(id: string, name: string, args: Record): ToolCall { - return toolCallFor(id, name, args); -} - function ruleSubject(toolName: string, args: Record): string | undefined { switch (toolName) { case 'Bash': @@ -788,8 +426,6 @@ function description(toolName: string): string { return 'write file'; case 'Edit': return 'edit file'; - case 'ExitPlanMode': - return 'Presenting plan and exiting plan mode'; default: return `Approve ${toolName}`; } @@ -881,37 +517,3 @@ function kaosStub(pathClass: HostEnvironmentService['pathClass'] = 'posix'): Hos ready: Promise.resolve(), }; } - -function planServiceStub( - status: () => PlanData | Promise, - exit: IAgentPlanService['exit'] = () => {}, -): Partial { - return { - status: async () => status(), - exit, - }; -} - -function swarmServiceStub(isActive: () => boolean): Partial { - return { - get isActive() { - return isActive(); - }, - }; -} - -function planData(path: string): NonNullable { - return { - id: 'plan-1', - content: '# Plan', - path, - }; -} - -function planReviewDisplay(input: { readonly plan: string }): ToolInputDisplay { - return { - kind: 'plan_review', - plan: input.plan, - path: '/tmp/plan.md', - }; -} diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts index 8050fd5211..7fc6683efa 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts @@ -52,8 +52,19 @@ describe('DefaultToolApprovePermissionPolicyService', () => { ['WebSearch', { query: 'kimi code' }], ['FetchURL', { url: 'https://example.com' }], ['Agent', { prompt: 'review this' }], + [ + 'AgentSwarm', + { + description: 'Check files', + prompt_template: 'Check {{item}}', + items: ['a.ts', 'b.ts'], + }, + ], ['AskUserQuestion', { questions: [] }], ['Skill', { name: 'test-skill' }], + ['EnterPlanMode', {}], + ['ExitPlanMode', {}], + ['CreateGoal', { title: 'ship it' }], ['GetGoal', {}], ['SetGoalBudget', { tokenBudget: 1000 }], ['UpdateGoal', { status: 'complete' }], @@ -68,14 +79,6 @@ describe('DefaultToolApprovePermissionPolicyService', () => { ['Custom', { value: 1 }], ['CronCreate', { cron: '*/5 * * * *', prompt: 'ping' }], ['CronDelete', { id: 'job_1' }], - [ - 'AgentSwarm', - { - description: 'Check files', - prompt_template: 'Check {{item}}', - items: ['a.ts', 'b.ts'], - }, - ], ] as const)('does not approve %s', (toolName, args) => { expect( policy.evaluate(policyContext(toolName, args)), diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts deleted file mode 100644 index 939d805830..0000000000 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/exit-plan-mode-review-ask.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import type { ToolCall } from '#/kosong/contract/message'; -import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import type { ApprovalResponse } from '#/session/approval/approval'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { DisposableStore } from '#/_base/di/lifecycle'; -import { createServices, type TestInstantiationService } from '#/_base/di/test'; -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import type { PermissionMode } from '#/agent/permissionPolicy/types'; -import { ExitPlanModeReviewAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/exit-plan-mode-review-ask'; -import { IAgentPlanService, type IAgentPlanService as AgentPlanService } from '#/agent/plan/plan'; -import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; -import { ITelemetryService } from '#/app/telemetry/telemetry'; -import { ToolAccesses } from '#/tool/toolContract'; - -import { stubPermissionModeService } from '../../permissionMode/stubs'; -import { recordingTelemetry, type TelemetryRecord } from '../../../app/telemetry/stubs'; - -const options = [ - { label: 'Approach A', description: 'Small change.' }, - { label: 'Approach B', description: 'Larger change.' }, -] as const; - -type ExitPlanModeFn = AgentPlanService['exit']; - -interface RuntimeApprovalResponse { - readonly decision: ApprovalResponse['decision']; - readonly selectedLabel?: string; - readonly feedback?: string; -} - -function approvalResponse(response: RuntimeApprovalResponse): ApprovalResponse { - return response as unknown as ApprovalResponse; -} - -function planReviewDisplay( - input: { - readonly plan?: string; - readonly options?: readonly (typeof options)[number][] | undefined; - } = {}, -): ToolInputDisplay { - const display: ToolInputDisplay = { - kind: 'plan_review', - plan: input.plan ?? '# Plan', - path: '/tmp/kimi-plan.md', - }; - if (input.options !== undefined) { - display.options = input.options; - } - return display; -} - -function policyContext(display: ToolInputDisplay): ResolvedToolExecutionHookContext { - const toolCall: ToolCall = { - type: 'function', - id: 'call_exit_plan', - name: 'ExitPlanMode', - arguments: '{}', - }; - return { - turnId: 7, - signal: new AbortController().signal, - toolCall, - toolCalls: [toolCall], - args: {}, - execution: { - accesses: ToolAccesses.none(), - approvalRule: 'ExitPlanMode', - display, - execute: async () => ({ output: '' }), - }, - }; -} - -function planService(exitPlanMode: ExitPlanModeFn = vi.fn()): AgentPlanService { - return { - _serviceBrand: undefined, - enter: async () => {}, - cancel: () => {}, - clear: async () => {}, - exit: exitPlanMode, - status: async () => ({ - id: 'plan-1', - content: '# Plan', - path: '/tmp/kimi-plan.md', - }), - }; -} - -describe('ExitPlanModeReviewAskPermissionPolicyService telemetry', () => { - let disposables: DisposableStore; - let ix: TestInstantiationService; - let records: TelemetryRecord[]; - let mode: PermissionMode; - - beforeEach(() => { - disposables = new DisposableStore(); - records = []; - mode = 'manual'; - ix = createServices(disposables, { - additionalServices: (reg) => { - reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); - reg.defineInstance( - IAgentScopeContext, - makeAgentScopeContext({ agentId: 'main', agentScope: '' }), - ); - reg.defineInstance(ITelemetryService, recordingTelemetry(records)); - }, - }); - }); - - afterEach(() => { - disposables.dispose(); - }); - - function makePolicy( - exitPlanMode?: ExitPlanModeFn, - ): ExitPlanModeReviewAskPermissionPolicyService { - ix.set(IAgentPlanService, planService(exitPlanMode)); - return ix.createInstance(ExitPlanModeReviewAskPermissionPolicyService); - } - - it('does not ask or track when auto mode approves upstream', async () => { - mode = 'auto'; - const result = await makePolicy().evaluate(policyContext(planReviewDisplay())); - - expect(result).toBeUndefined(); - expect(records).toEqual([]); - }); - - it('tracks submitted before asking for manual plan approval', async () => { - const result = await makePolicy().evaluate(policyContext(planReviewDisplay())); - - expect(result?.kind).toBe('ask'); - expect(records).toContainEqual({ - event: 'plan_submitted', - properties: { has_options: false }, - }); - }); - - it('tracks approved multi-option plans with the chosen option', async () => { - const exitPlanMode = vi.fn(); - const result = await makePolicy(exitPlanMode).evaluate( - policyContext(planReviewDisplay({ options })), - ); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ - decision: 'approved', - selectedLabel: 'Approach B', - })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: false, - output: expect.stringContaining('Selected approach: Approach B'), - }, - }); - expect(exitPlanMode).toHaveBeenCalledTimes(1); - expect(records).toContainEqual({ - event: 'plan_submitted', - properties: { has_options: true }, - }); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { - outcome: 'approved', - chosen_option: 'Approach B', - }, - }); - }); - - it('records a revise outcome with feedback and keeps plan mode active when the user requests changes', async () => { - const exitPlanMode = vi.fn(); - const result = await makePolicy(exitPlanMode).evaluate(policyContext(planReviewDisplay())); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ - decision: 'rejected', - selectedLabel: 'Revise', - feedback: 'Add verification.', - })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: false, - output: expect.stringContaining('Add verification.'), - }, - }); - expect(exitPlanMode).not.toHaveBeenCalled(); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { - outcome: 'revise', - has_feedback: true, - }, - }); - }); - - it('keeps plan mode active and records a rejected outcome when the user rejects the plan', async () => { - const exitPlanMode = vi.fn(); - const result = await makePolicy(exitPlanMode).evaluate(policyContext(planReviewDisplay())); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ decision: 'rejected' })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: true, - output: 'Plan rejected by user. Plan mode remains active.', - }, - }); - expect(exitPlanMode).not.toHaveBeenCalled(); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { outcome: 'rejected' }, - }); - }); - - it('keeps plan mode active and records a dismissed outcome when the approval dialog is cancelled', async () => { - const exitPlanMode = vi.fn(); - const result = await makePolicy(exitPlanMode).evaluate(policyContext(planReviewDisplay())); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ decision: 'cancelled' })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: false, - output: 'Plan approval dismissed. Plan mode remains active.', - }, - }); - expect(exitPlanMode).not.toHaveBeenCalled(); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { outcome: 'dismissed' }, - }); - }); - - it('exits plan mode and records a rejected_and_exited outcome when the user chooses reject and exit', async () => { - const exitPlanMode = vi.fn(); - const result = await makePolicy(exitPlanMode).evaluate(policyContext(planReviewDisplay())); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ - decision: 'rejected', - selectedLabel: 'Reject and Exit', - })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: true, - output: 'Plan rejected by user. Plan mode deactivated.', - }, - }); - expect(exitPlanMode).toHaveBeenCalledTimes(1); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { outcome: 'rejected_and_exited' }, - }); - }); - - it('returns approved plan output without a saved-to line when display has no path', async () => { - const display: ToolInputDisplay = { - kind: 'plan_review', - plan: '# Draft Plan', - }; - const result = await makePolicy().evaluate(policyContext(display)); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ decision: 'approved' })); - - expect(approval).toMatchObject({ - kind: 'result', - syntheticResult: { - isError: false, - output: expect.stringContaining('## Approved Plan:\n# Draft Plan'), - }, - }); - expect(approval?.kind === 'result' ? approval.syntheticResult?.output : '').not.toContain( - 'Plan saved to:', - ); - }); - - it('does not force a selected-approach prefix for labels that are not in the options', async () => { - const result = await makePolicy().evaluate(policyContext(planReviewDisplay({ options }))); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - const approval = result.resolveApproval?.(approvalResponse({ - decision: 'approved', - selectedLabel: 'Approach C', - })); - - expect(approval?.kind === 'result' ? approval.syntheticResult?.output : '').not.toContain( - 'Selected approach:', - ); - expect(records).toContainEqual({ - event: 'plan_resolved', - properties: { - outcome: 'approved', - chosen_option: 'Approach C', - }, - }); - }); - - it('propagates exit errors without tracking approved resolution', async () => { - const exitPlanMode = vi.fn(() => { - throw new Error('state transition failure'); - }); - const result = await makePolicy(exitPlanMode).evaluate(policyContext(planReviewDisplay())); - if (result?.kind !== 'ask') throw new Error('expected ask'); - - expect(() => result.resolveApproval?.(approvalResponse({ decision: 'approved' }))).toThrow( - 'state transition failure', - ); - expect(records).toContainEqual({ - event: 'plan_submitted', - properties: { has_options: false }, - }); - expect(records).not.toContainEqual({ - event: 'plan_resolved', - properties: { outcome: 'approved' }, - }); - }); -}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts deleted file mode 100644 index c6f714eef1..0000000000 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/goal-start-review-ask.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { ToolCall } from '#/kosong/contract/message'; -import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; -import { describe, expect, it } from 'vitest'; - -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import type { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; -import { GoalStartReviewAskPermissionPolicyService } from '#/agent/permissionPolicy/policies/goal-start-review-ask'; -import { ToolAccesses } from '#/tool/toolContract'; - -const signal = new AbortController().signal; -type PermissionMode = IAgentPermissionModeService['mode']; - -function fakeModeService(initialMode: PermissionMode) { - let currentMode = initialMode; - return { - get mode() { - return currentMode; - }, - setMode(mode: PermissionMode) { - currentMode = mode; - }, - } as IAgentPermissionModeService; -} - -function policyContext( - toolName: string, - display: ToolInputDisplay | undefined, -): ResolvedToolExecutionHookContext { - return { - turnId: '0', - stepNumber: 1, - signal, - llm: {}, - args: {}, - toolCall: { - type: 'function', - id: `call_${toolName}`, - name: toolName, - arguments: '{}', - } satisfies ToolCall, - execution: { - accesses: ToolAccesses.none(), - approvalRule: toolName, - display, - execute: async () => ({ output: '' }), - }, - } as unknown as ResolvedToolExecutionHookContext; -} - -const GOAL_DISPLAY: ToolInputDisplay = { - kind: 'goal_start', - objective: 'Fix the failing auth tests', - mode: 'manual', -}; - -describe('GoalStartReviewAskPermissionPolicyService', () => { - it('ignores tools other than CreateGoal', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - expect(policy.evaluate(policyContext('Bash', undefined))).toBeUndefined(); - }); - - it('does not ask in auto mode (the goal is auto-approved upstream)', () => { - const mode = fakeModeService('auto'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - expect(policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY))).toBeUndefined(); - }); - - it('does not ask without a goal_start display', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - expect(policy.evaluate(policyContext('CreateGoal', undefined))).toBeUndefined(); - }); - - it('asks with the start menu for a CreateGoal in manual mode', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); - expect(result?.kind).toBe('ask'); - }); - - it('switches to the chosen mode on approval, then lets the goal be created', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); - if (result?.kind !== 'ask') throw new Error('expected ask'); - expect(result.resolveApproval?.({ decision: 'approved', selectedLabel: 'auto' })).toBeUndefined(); - expect(mode.mode).toBe('auto'); - }); - - it('keeps the current mode when the user starts in manual', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); - if (result?.kind !== 'ask') throw new Error('expected ask'); - expect(result.resolveApproval?.({ decision: 'approved', selectedLabel: 'manual' })).toBeUndefined(); - expect(mode.mode).toBe('manual'); - }); - - it('creates no goal and changes no mode when the user declines', () => { - const mode = fakeModeService('manual'); - const policy = new GoalStartReviewAskPermissionPolicyService(mode); - const result = policy.evaluate(policyContext('CreateGoal', GOAL_DISPLAY)); - if (result?.kind !== 'ask') throw new Error('expected ask'); - expect(result.resolveApproval?.({ decision: 'cancelled', selectedLabel: 'cancel' })).toBeUndefined(); - expect(mode.mode).toBe('manual'); - }); -}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/plan-mode-guard-deny.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/plan-mode-guard-deny.test.ts deleted file mode 100644 index 296e114af9..0000000000 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/plan-mode-guard-deny.test.ts +++ /dev/null @@ -1,234 +0,0 @@ -import type { ToolCall } from '#/kosong/contract/message'; -import { describe, expect, it } from 'vitest'; - -import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; -import { IAgentPlanService, type PlanData } from '#/agent/plan/plan'; -import { PlanModeGuardDenyPermissionPolicyService } from '#/agent/permissionPolicy/policies/plan-mode-guard-deny'; -import { ToolAccesses } from '#/tool/toolContract'; - -const signal = new AbortController().signal; -const PLAN_PATH = '/workspace/plan/current-plan.md'; - -function planService(active: boolean, planFilePath: string | null = PLAN_PATH): IAgentPlanService { - return { - _serviceBrand: undefined, - enter: async () => {}, - cancel: () => {}, - clear: async () => {}, - exit: () => {}, - status: async () => - active - ? ({ - id: 'current-plan', - content: '# Plan', - path: planFilePath ?? PLAN_PATH, - } satisfies NonNullable) - : null, - }; -} - -function toolCall(name: string, args: Record): ToolCall { - return { - type: 'function', - id: `call_${name.toLowerCase()}`, - name, - arguments: JSON.stringify(args), - }; -} - -function policyContext( - toolName: string, - args: Record, - accesses: ReturnType = ToolAccesses.none(), -): ResolvedToolExecutionHookContext { - const call = toolCall(toolName, args); - return { - turnId: 0, - signal, - toolCall: call, - toolCalls: [call], - args, - execution: { - accesses, - approvalRule: toolName, - execute: async () => ({ output: '' }), - }, - }; -} - -function evaluate( - policy: PlanModeGuardDenyPermissionPolicyService, - toolName: string, - args: Record, - accesses?: ReturnType, -) { - return policy.evaluate(policyContext(toolName, args, accesses)); -} - -function expectDeny(result: Awaited>) { - expect(result).toMatchObject({ kind: 'deny' }); - if (result?.kind !== 'deny') throw new Error('expected deny result'); - return result; -} - -describe('PlanModeGuardDenyPermissionPolicyService', () => { - it('allows Write and Edit to the active plan file', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - expect( - await evaluate(policy, 'Write', { path: PLAN_PATH, content: '# Plan' }, ToolAccesses.writeFile(PLAN_PATH)), - ).toBeUndefined(); - expect( - await evaluate( - policy, - 'Edit', - { path: PLAN_PATH, old_string: 'A', new_string: 'B' }, - ToolAccesses.readWriteFile(PLAN_PATH), - ), - ).toBeUndefined(); - }); - - it('blocks Write and Edit to non-plan files before permission approval', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - const otherPath = '/workspace/src/main.ts'; - - const write = await evaluate( - policy, - 'Write', - { path: otherPath, content: 'x' }, - ToolAccesses.writeFile(otherPath), - ); - const edit = await evaluate( - policy, - 'Edit', - { path: otherPath, old_string: 'A', new_string: 'B' }, - ToolAccesses.readWriteFile(otherPath), - ); - - expect(expectDeny(write).message).toContain('current plan file'); - expect(expectDeny(write).message).toContain('ExitPlanMode'); - expect(expectDeny(edit).message).toContain('current plan file'); - }); - - it('blocks Write and Edit with no file write access while plan mode is active', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const write = await evaluate(policy, 'Write', { content: 'x' }, ToolAccesses.none()); - const edit = await evaluate( - policy, - 'Edit', - { old_string: 'A', new_string: 'B' }, - ToolAccesses.none(), - ); - - expectDeny(write); - expectDeny(edit); - }); - - it('allows multiple writes when every write access targets the active plan file', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const result = await evaluate( - policy, - 'Write', - { path: PLAN_PATH, content: 'x' }, - [ - { kind: 'file', operation: 'write', path: PLAN_PATH }, - { kind: 'file', operation: 'readwrite', path: PLAN_PATH }, - ], - ); - - expect(result).toBeUndefined(); - }); - - it('blocks mixed plan-file and non-plan-file write accesses', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const result = await evaluate( - policy, - 'Edit', - { path: PLAN_PATH, old_string: 'A', new_string: 'B' }, - [ - { kind: 'file', operation: 'readwrite', path: PLAN_PATH }, - { kind: 'file', operation: 'write', path: '/workspace/src/main.ts' }, - ], - ); - - const deny = expectDeny(result); - expect(deny.message).toContain('current plan file'); - }); - - it('does not block read-only tools while plan mode is active', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - expect(await evaluate(policy, 'Read', { path: '/workspace/src/main.ts' })).toBeUndefined(); - expect(await evaluate(policy, 'Grep', { pattern: 'TODO', path: '/workspace' })).toBeUndefined(); - }); - - it.each(['manual', 'yolo', 'auto'] as const)( - 'defers Bash to ordinary %s permission handling while plan mode is active', - async (_mode) => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - expect(await evaluate(policy, 'Bash', { command: 'rm foo.txt' })).toBeUndefined(); - expect(await evaluate(policy, 'Bash', { command: 'ls -la' })).toBeUndefined(); - }, - ); - - it.each(['manual', 'yolo', 'auto'] as const)( - 'blocks TaskStop while plan mode is active in %s mode', - async (_mode) => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const result = await evaluate(policy, 'TaskStop', { task_id: 'bash-abc12345' }); - - const deny = expectDeny(result); - expect(deny.message).toContain('plan mode'); - expect(deny.message).toContain('ExitPlanMode'); - }, - ); - - it('denies CronCreate when plan mode is active', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const result = await evaluate(policy, 'CronCreate', { - cron: '*/5 * * * *', - prompt: 'ping', - }); - - const deny = expectDeny(result); - expect(deny.message).toContain('CronCreate'); - expect(deny.message).toContain('plan mode'); - }); - - it('denies CronDelete when plan mode is active', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - const result = await evaluate(policy, 'CronDelete', { id: 'job_1' }); - - const deny = expectDeny(result); - expect(deny.message).toContain('CronDelete'); - expect(deny.message).toContain('plan mode'); - }); - - it('allows CronList when plan mode is active', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(true)); - - expect(await evaluate(policy, 'CronList', {})).toBeUndefined(); - }); - - it('does not block anything once plan mode has exited', async () => { - const policy = new PlanModeGuardDenyPermissionPolicyService(planService(false)); - - expect( - await evaluate( - policy, - 'Write', - { path: '/workspace/src/main.ts', content: 'x' }, - ToolAccesses.writeFile('/workspace/src/main.ts'), - ), - ).toBeUndefined(); - expect(await evaluate(policy, 'Bash', { command: 'rm foo.txt' })).toBeUndefined(); - expect(await evaluate(policy, 'TaskStop', { task_id: 'bash-abc12345' })).toBeUndefined(); - }); -}); diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/stubs.ts b/packages/agent-core-v2/test/agent/permissionPolicy/stubs.ts index 370da938d7..2ade0a8cdb 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/stubs.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/stubs.ts @@ -18,6 +18,5 @@ export function stubPermissionPolicyService( return { _serviceBrand: undefined, evaluate: () => Promise.resolve(next()), - registerPolicy: () => ({ dispose: () => {} }), }; } diff --git a/packages/agent-core-v2/test/agent/plan/plan.test.ts b/packages/agent-core-v2/test/agent/plan/plan.test.ts index ca7fa17992..082f13c7dc 100644 --- a/packages/agent-core-v2/test/agent/plan/plan.test.ts +++ b/packages/agent-core-v2/test/agent/plan/plan.test.ts @@ -514,7 +514,7 @@ describe('Plan service', () => { }, ); - it('keeps explicit deny rules above active plan file writes', async () => { + it('short-circuits active plan file writes ahead of explicit deny rules', async () => { const files = new Map(); const writeText = vi.fn(async (path: string, content: string): Promise => { files.set(path, content); @@ -548,11 +548,12 @@ describe('Plan service', () => { await ctx.untilTurnEnd(); - expect(files.get(planPath)).toBeUndefined(); - expect(writeText).not.toHaveBeenCalled(); - expect(toolResultText(context.get())).toContain( - 'Tool "Write" was denied by permission rule. Reason: blocked by test', - ); + // The plan-guard hook lets plan-file writes through before the + // permission chain runs, so user-configured deny rules no longer + // adjudicate them. + expect(files.get(planPath)).toBe(content); + expect(writeText).toHaveBeenCalledWith(planPath, content); + expect(toolResultText(context.get())).not.toContain('denied by permission rule'); expect( ctx.allEvents.some((event) => event.type === '[rpc]' && event.event === 'requestApproval'), ).toBe(false); diff --git a/packages/agent-core-v2/test/agent/plan/planGuard.test.ts b/packages/agent-core-v2/test/agent/plan/planGuard.test.ts new file mode 100644 index 0000000000..171fb29fd6 --- /dev/null +++ b/packages/agent-core-v2/test/agent/plan/planGuard.test.ts @@ -0,0 +1,569 @@ +/** + * Scenario: plan-mode Harness constraints as the `'plan-guard'` executor hook. + * Responsibilities: verify Write/Edit plan-file short-circuit and denies, + * TaskStop/Cron denies, fall-through delegation, and every ExitPlanMode review + * branch (approve with/without option, Reject and Exit, Revise, dismiss, + * auto / no-plan / empty-plan / non-plan_review skips) with telemetry. + * Wiring: real wire and plan services; the executor slot carries a + * `'permission'` stand-in so hook ordering and next() delegation are + * observable; `IAgentToolApprovalService` is a recording stub. + * Run: `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run test/agent/plan/planGuard.test.ts`. + */ + +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices, type TestInstantiationService } from '#/_base/di/test'; +import { IAgentContextInjectorService } from '#/agent/contextInjector/contextInjector'; +import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + ApprovalResponse, + PermissionMode, + PermissionPolicyResolution, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { IAgentPlanService } from '#/agent/plan/plan'; +import { AgentPlanService } from '#/agent/plan/planService'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + AuthorizeToolExecutionResult, + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; +import { IAgentTelemetryContextService } from '#/app/telemetry/agentTelemetryContext'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import { OrderedHookSlot } from '#/hooks'; +import type { ToolCall } from '#/kosong/contract/message'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { ISessionContext } from '#/session/sessionContext/sessionContext'; +import { ToolAccesses } from '#/tool/toolContract'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; +import { createFakeHostFs } from '../../tools/fixtures/fake-exec'; +import { registerTestAgentWireServices } from '../../wire/stubs'; +import { stubPermissionModeService } from '../permissionMode/stubs'; + +const signal = new AbortController().signal; +const SESSION_DIR = '/session'; +const PLAN_ID = 'plan-1'; +const PLAN_PATH = `${SESSION_DIR}/agents/test-agent/plans/${PLAN_ID}.md`; + +const options = [ + { label: 'Approach A', description: 'Small change.' }, + { label: 'Approach B', description: 'Larger change.' }, +] as const; + +type AskResult = Extract; + +interface ApprovalRequestRecord { + readonly ask: AskResult; + readonly origin: string; +} + +function toolCall(name: string, args: Record): ToolCall { + return { + type: 'function', + id: `call_${name.toLowerCase()}`, + name, + arguments: JSON.stringify(args), + }; +} + +function hookContext( + toolName: string, + input: { + readonly args?: Record; + readonly accesses?: ToolAccesses; + readonly display?: ToolInputDisplay; + } = {}, +): ToolBeforeExecuteContext { + const args = input.args ?? {}; + const call = toolCall(toolName, args); + return { + turnId: 0, + signal, + toolCall: call, + toolCalls: [call], + args, + execution: { + accesses: input.accesses, + display: input.display, + approvalRule: toolName, + execute: async () => ({ output: '' }), + }, + }; +} + +function planReviewDisplay( + input: { + readonly plan?: string; + readonly path?: string | undefined; + readonly options?: readonly (typeof options)[number][] | undefined; + } = {}, +): ToolInputDisplay { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: input.plan ?? '# Plan', + }; + const path = 'path' in input ? input.path : PLAN_PATH; + if (path !== undefined) { + display.path = path; + } + if (input.options !== undefined) { + display.options = input.options; + } + return display; +} + +function mapResolution( + resolution: PermissionPolicyResolution | undefined, +): AuthorizeToolExecutionResult | undefined { + if (resolution === undefined) return undefined; + if (resolution.kind !== 'result') { + throw new Error('the review stub only resolves synthetic results'); + } + const { kind: _kind, ...result } = resolution; + return result; +} + +describe('AgentPlanService plan-guard hook', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let beforeSlot: OrderedHookSlot; + let permissionRan: boolean; + let records: TelemetryRecord[]; + let requests: ApprovalRequestRecord[]; + let approvalResponse: ApprovalResponse; + let formatDenyMessage: Mock<(message: string) => string>; + let mode: PermissionMode; + let files: Map; + + beforeEach(() => { + disposables = new DisposableStore(); + records = []; + requests = []; + approvalResponse = { decision: 'approved' }; + formatDenyMessage = vi.fn((message: string) => message); + mode = 'manual'; + files = new Map(); + permissionRan = false; + + beforeSlot = new OrderedHookSlot(); + // The plan-guard hook registers `before: 'permission'`, so the slot needs + // a stand-in for the permissionGate hook; the flag proves whether the + // hook short-circuited or delegated via next(). + beforeSlot.register('permission', async (_ctx, next) => { + permissionRan = true; + await next(); + }); + + const toolApproval: IAgentToolApprovalService = { + _serviceBrand: undefined, + resolvePermissionResolution: async () => { + throw new Error('resolvePermissionResolution is not used by the plan-guard hook'); + }, + requestToolApproval: async (_context, ask, origin) => { + requests.push({ ask, origin }); + return mapResolution(ask.resolveApproval?.(approvalResponse)); + }, + formatDenyMessage: (message: string) => formatDenyMessage(message), + formatApprovalRejectionMessage: (toolName, result) => + `Tool "${toolName}" was not run (${result.decision}).`, + }; + + ix = createServices(disposables, { + additionalServices: (reg) => { + registerTestAgentWireServices(reg); + reg.defineInstance( + IHostFileSystem, + createFakeHostFs({ + mkdir: vi.fn().mockResolvedValue(undefined), + readText: vi.fn(async (path: string) => files.get(path) ?? ''), + writeText: vi.fn(async (path: string, content: string) => { + files.set(path, content); + }), + }), + ); + reg.definePartialInstance(ISessionContext, { + sessionId: 'session-1', + sessionDir: SESSION_DIR, + }); + reg.definePartialInstance(IAgentContextMemoryService, {}); + reg.definePartialInstance(IAgentContextInjectorService, { + register: () => ({ dispose: () => {} }), + }); + reg.definePartialInstance(IAgentTelemetryContextService, { set: () => {} }); + reg.definePartialInstance(IAgentToolExecutorService, { + hooks: { + onBeforeExecuteTool: beforeSlot, + onDidExecuteTool: new OrderedHookSlot(), + }, + }); + reg.defineInstance(IAgentToolApprovalService, toolApproval); + reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance(ITelemetryService, recordingTelemetry(records)); + reg.define(IAgentPlanService, AgentPlanService); + }, + }); + }); + + afterEach(() => disposables.dispose()); + + function plan(): IAgentPlanService { + return ix.get(IAgentPlanService); + } + + async function enterPlan(): Promise { + const svc = plan(); + await svc.enter(PLAN_ID); + return svc; + } + + async function run(ctx: ToolBeforeExecuteContext): Promise { + await beforeSlot.run(ctx); + return ctx; + } + + describe('guard', () => { + it.each(['Write', 'Edit'] as const)( + 'lets a %s that only targets the active plan file through without the permission chain', + async (toolName) => { + await enterPlan(); + const ctx = await run( + hookContext(toolName, { + args: { path: PLAN_PATH }, + accesses: ToolAccesses.writeFile(PLAN_PATH), + }), + ); + + expect(ctx.decision).toEqual({}); + expect(permissionRan).toBe(false); + }, + ); + + it('lets multiple writes through when every write access targets the active plan file', async () => { + await enterPlan(); + const ctx = await run( + hookContext('Edit', { + args: { path: PLAN_PATH }, + accesses: [ + { kind: 'file', operation: 'write', path: PLAN_PATH }, + { kind: 'file', operation: 'readwrite', path: PLAN_PATH }, + ], + }), + ); + + expect(ctx.decision).toEqual({}); + expect(permissionRan).toBe(false); + }); + + it.each(['Write', 'Edit'] as const)( + 'blocks a %s to a non-plan file with a formatted deny reason', + async (toolName) => { + await enterPlan(); + const otherPath = '/workspace/src/main.ts'; + const ctx = await run( + hookContext(toolName, { + args: { path: otherPath }, + accesses: ToolAccesses.writeFile(otherPath), + }), + ); + + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('current plan file'); + expect(ctx.decision?.reason).toContain('ExitPlanMode'); + expect(formatDenyMessage).toHaveBeenCalledWith( + expect.stringContaining(PLAN_PATH), + ); + expect(permissionRan).toBe(false); + }, + ); + + it('blocks Write and Edit with no file write access while plan mode is active', async () => { + await enterPlan(); + + for (const toolName of ['Write', 'Edit'] as const) { + const ctx = await run( + hookContext(toolName, { args: {}, accesses: ToolAccesses.none() }), + ); + expect(ctx.decision?.block).toBe(true); + } + expect(permissionRan).toBe(false); + }); + + it('blocks mixed plan-file and non-plan-file write accesses', async () => { + await enterPlan(); + const ctx = await run( + hookContext('Edit', { + args: { path: PLAN_PATH }, + accesses: [ + { kind: 'file', operation: 'readwrite', path: PLAN_PATH }, + { kind: 'file', operation: 'write', path: '/workspace/src/main.ts' }, + ], + }), + ); + + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('current plan file'); + expect(permissionRan).toBe(false); + }); + + it('blocks TaskStop while plan mode is active', async () => { + await enterPlan(); + const ctx = await run(hookContext('TaskStop', { args: { task_id: 'bash-abc12345' } })); + + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain('TaskStop'); + expect(ctx.decision?.reason).toContain('ExitPlanMode'); + expect(permissionRan).toBe(false); + }); + + it.each(['CronCreate', 'CronDelete'] as const)( + 'blocks %s while plan mode is active', + async (toolName) => { + await enterPlan(); + const ctx = await run(hookContext(toolName, { args: {} })); + + expect(ctx.decision?.block).toBe(true); + expect(ctx.decision?.reason).toContain(toolName); + expect(ctx.decision?.reason).toContain('plan mode'); + expect(permissionRan).toBe(false); + }, + ); + + it.each(['Read', 'Grep', 'Bash', 'CronList'] as const)( + 'delegates %s to the permission chain while plan mode is active', + async (toolName) => { + await enterPlan(); + const ctx = await run(hookContext(toolName, { args: {} })); + + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }, + ); + + it('delegates everything once plan mode has exited', async () => { + plan(); + const ctx = await run( + hookContext('Write', { + args: { path: '/workspace/src/main.ts' }, + accesses: ToolAccesses.writeFile('/workspace/src/main.ts'), + }), + ); + + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + }); + + describe('exit plan mode review', () => { + it('asks through toolApproval under the legacy origin and tracks plan_submitted', async () => { + await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(requests).toHaveLength(1); + expect(requests[0]?.origin).toBe('exit-plan-mode-review-ask'); + expect(requests[0]?.ask.kind).toBe('ask'); + expect(requests[0]?.ask.reason).toEqual({ has_options: false }); + expect(records).toContainEqual({ + event: 'plan_submitted', + properties: { has_options: false }, + }); + expect(ctx.decision?.syntheticResult).toBeDefined(); + expect(permissionRan).toBe(false); + }); + + it('approves with the chosen option prefix and tracks the chosen option', async () => { + const svc = await enterPlan(); + approvalResponse = { decision: 'approved', selectedLabel: 'Approach B' }; + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay({ options }) }), + ); + + expect(ctx.decision?.syntheticResult?.isError).toBe(false); + expect(ctx.decision?.syntheticResult?.output).toContain( + 'Selected approach: Approach B', + ); + expect(ctx.decision?.syntheticResult?.output).toContain( + 'Execute ONLY the selected approach', + ); + expect(ctx.decision?.syntheticResult?.output).toContain('## Approved Plan:\n# Plan'); + expect(records).toContainEqual({ + event: 'plan_submitted', + properties: { has_options: true }, + }); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'approved', chosen_option: 'Approach B' }, + }); + expect(await svc.status()).toBeNull(); + }); + + it('approves without a selected label and saves the plan path into the output', async () => { + const svc = await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(ctx.decision?.syntheticResult?.output).toContain( + `Plan saved to: ${PLAN_PATH}`, + ); + expect(ctx.decision?.syntheticResult?.output).not.toContain('Selected approach:'); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'approved' }, + }); + expect(await svc.status()).toBeNull(); + }); + + it('omits the saved-to line when the display has no path', async () => { + await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { + display: planReviewDisplay({ plan: '# Draft Plan', path: undefined }), + }), + ); + + expect(ctx.decision?.syntheticResult?.output).toContain('## Approved Plan:\n# Draft Plan'); + expect(ctx.decision?.syntheticResult?.output).not.toContain('Plan saved to:'); + }); + + it('exits plan mode with a stopping error result when the user chooses Reject and Exit', async () => { + const svc = await enterPlan(); + approvalResponse = { decision: 'rejected', selectedLabel: 'Reject and Exit' }; + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(ctx.decision?.syntheticResult).toMatchObject({ + isError: true, + stopTurn: true, + output: 'Plan rejected by user. Plan mode deactivated.', + }); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'rejected_and_exited' }, + }); + expect(await svc.status()).toBeNull(); + }); + + it('keeps plan mode active with the feedback result when the user requests revisions', async () => { + const svc = await enterPlan(); + approvalResponse = { + decision: 'rejected', + selectedLabel: 'Revise', + feedback: 'Add verification.', + }; + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(ctx.decision?.syntheticResult?.isError).toBe(false); + expect(ctx.decision?.syntheticResult?.output).toContain('Add verification.'); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'revise', has_feedback: true }, + }); + expect(await svc.status()).not.toBeNull(); + }); + + it('keeps plan mode active with a stopping error result when the user rejects the plan', async () => { + const svc = await enterPlan(); + approvalResponse = { decision: 'rejected' }; + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(ctx.decision?.syntheticResult).toMatchObject({ + isError: true, + stopTurn: true, + output: 'Plan rejected by user. Plan mode remains active.', + }); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'rejected' }, + }); + expect(await svc.status()).not.toBeNull(); + }); + + it('keeps plan mode active with a dismissed result when the approval is cancelled', async () => { + const svc = await enterPlan(); + approvalResponse = { decision: 'cancelled' }; + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(ctx.decision?.syntheticResult).toMatchObject({ + isError: false, + output: 'Plan approval dismissed. Plan mode remains active.', + }); + expect(records).toContainEqual({ + event: 'plan_resolved', + properties: { outcome: 'dismissed' }, + }); + expect(await svc.status()).not.toBeNull(); + }); + + it('skips the review in auto mode', async () => { + mode = 'auto'; + await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(requests).toHaveLength(0); + expect(records).toEqual([]); + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + + it('skips the review when no plan is active', async () => { + plan(); + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay() }), + ); + + expect(requests).toHaveLength(0); + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + + it('skips the review when the plan is empty', async () => { + await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { display: planReviewDisplay({ plan: ' ' }) }), + ); + + expect(requests).toHaveLength(0); + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + + it('skips the review for a non-plan_review display', async () => { + await enterPlan(); + const ctx = await run( + hookContext('ExitPlanMode', { + display: { kind: 'generic', summary: 'Presenting plan', detail: {} }, + }), + ); + + expect(requests).toHaveLength(0); + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + + it('skips the review when the display is missing', async () => { + await enterPlan(); + const ctx = await run(hookContext('ExitPlanMode')); + + expect(requests).toHaveLength(0); + expect(ctx.decision).toBeUndefined(); + expect(permissionRan).toBe(true); + }); + }); +}); diff --git a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts index 2ea36d12cd..756f13c18b 100644 --- a/packages/agent-core-v2/test/agent/swarm/swarm.test.ts +++ b/packages/agent-core-v2/test/agent/swarm/swarm.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; import { SyncDescriptor } from '#/_base/di/descriptors'; @@ -14,6 +14,14 @@ import { IAgentSwarmService } from '#/agent/swarm/swarm'; import { AgentSwarmService } from '#/agent/swarm/swarmService'; import { SwarmModel } from '#/agent/swarm/swarmOps'; import { AgentSwarmTool, AgentSwarmToolInputSchema } from '#/agent/swarm/tools/agent-swarm'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; +import { OrderedHookSlot } from '#/hooks'; +import type { ToolCall } from '#/kosong/contract/message'; import type { ExecutableToolContext } from '#/tool/toolContract'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; import { AgentToolRegistryService } from '#/agent/toolRegistry/toolRegistryService'; @@ -44,6 +52,21 @@ function context( return { turnId: 0, toolCallId, args, signal }; } +function toolCall(name: string, id: string): ToolCall { + return { type: 'function', id, name, arguments: '{}' }; +} + +function hookContext(toolCalls: ToolCall[]): ToolBeforeExecuteContext { + return { + turnId: 0, + signal, + toolCall: toolCalls[0]!, + toolCalls, + args: {}, + execution: { approvalRule: toolCalls[0]!.name, execute: async () => ({ output: '' }) }, + }; +} + function mockSwarmHost({ run = vi.fn().mockResolvedValue([]), getSwarmItem = vi.fn().mockResolvedValue(undefined), @@ -99,6 +122,9 @@ function stubCallerProfile( describe('AgentSwarmService', () => { let disposables: DisposableStore; let ix: TestInstantiationService; + let beforeExecuteSlot: OrderedHookSlot; + let permissionGateRan: boolean; + let formatDenyMessage: Mock<(message: string) => string>; beforeEach(() => { disposables = new DisposableStore(); @@ -115,6 +141,23 @@ describe('AgentSwarmService', () => { run: async () => [], cancel: () => {}, }); + // The swarm-exclusive hook registers `before: 'permission'`, so the slot + // needs a stand-in for the permissionGate hook; the flag proves whether + // the swarm hook short-circuited or delegated via next(). + beforeExecuteSlot = new OrderedHookSlot(); + permissionGateRan = false; + beforeExecuteSlot.register('permission', async (_ctx, next) => { + permissionGateRan = true; + await next(); + }); + ix.stub(IAgentToolExecutorService, { + hooks: { + onBeforeExecuteTool: beforeExecuteSlot, + onDidExecuteTool: new OrderedHookSlot(), + }, + }); + formatDenyMessage = vi.fn((message: string) => message); + ix.stub(IAgentToolApprovalService, { formatDenyMessage }); registerTestAgentWire(ix, testWireScope('wire', 'swarm-test'), { log: ix.get(IAppendLogStore), eventBus: ix.get(IEventBus), @@ -172,6 +215,59 @@ describe('AgentSwarmService', () => { ); expect(fresh.getModel(SwarmModel)).toBe('manual'); }); + + it('blocks a batch with multiple AgentSwarm calls before the permission gate', async () => { + ix.get(IAgentSwarmService); + const ctx = hookContext([ + toolCall('AgentSwarm', 'call_swarm_1'), + toolCall('AgentSwarm', 'call_swarm_2'), + ]); + + await beforeExecuteSlot.run(ctx); + + expect(ctx.decision).toEqual({ + block: true, + reason: expect.stringContaining('one swarm at a time'), + }); + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + + it('blocks an AgentSwarm call mixed with other tools in one batch', async () => { + ix.get(IAgentSwarmService); + const ctx = hookContext([toolCall('AgentSwarm', 'call_swarm'), toolCall('Bash', 'call_bash')]); + + await beforeExecuteSlot.run(ctx); + + expect(ctx.decision).toEqual({ + block: true, + reason: expect.stringContaining('must be the only tool call'), + }); + expect(permissionGateRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledTimes(1); + }); + + it('lets a single AgentSwarm call through to the permission gate', async () => { + ix.get(IAgentSwarmService); + const ctx = hookContext([toolCall('AgentSwarm', 'call_swarm')]); + + await beforeExecuteSlot.run(ctx); + + expect(ctx.decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); + + it('lets tool batches without AgentSwarm through to the permission gate', async () => { + ix.get(IAgentSwarmService); + const ctx = hookContext([toolCall('Bash', 'call_bash'), toolCall('Read', 'call_read')]); + + await beforeExecuteSlot.run(ctx); + + expect(ctx.decision).toBeUndefined(); + expect(permissionGateRan).toBe(true); + expect(formatDenyMessage).not.toHaveBeenCalled(); + }); }); describe('AgentSwarmTool', () => { diff --git a/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts new file mode 100644 index 0000000000..1de11e15e1 --- /dev/null +++ b/packages/agent-core-v2/test/agent/toolApproval/toolApproval.test.ts @@ -0,0 +1,621 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import type { TestInstantiationService } from '#/_base/di/test'; +import { UserCancellationError } from '#/_base/utils/abort'; +import type { ResolvedToolExecutionHookContext } from '#/agent/toolExecutor/toolHooks'; +import { IAgentPermissionModeService } from '#/agent/permissionMode/permissionMode'; +import type { + PermissionMode, + PermissionPolicyResult, +} from '#/agent/permissionPolicy/types'; +import { + IAgentPermissionRulesService, + type PermissionApprovalResultRecord, +} from '#/agent/permissionRules/permissionRules'; +import { IAgentScopeContext, makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { AgentToolApprovalService } from '#/agent/toolApproval/toolApprovalService'; +import { IEventBus } from '#/app/event/eventBus'; +import { EventBusService } from '#/app/event/eventBusService'; +import { ITelemetryService } from '#/app/telemetry/telemetry'; +import type { ToolCall } from '#/kosong/contract/message'; +import { + ISessionApprovalService, + type ApprovalRequest, + type ApprovalResponse, +} from '#/session/approval/approval'; +import { ISessionContext, makeSessionContext } from '#/session/sessionContext/sessionContext'; +import type { ToolInputDisplay } from '#/tool/toolInputDisplay'; + +import { stubPermissionModeService } from '../permissionMode/stubs'; +import { recordingTelemetry, type TelemetryRecord } from '../../app/telemetry/stubs'; + +const RETRY_GUIDANCE = + "Try a different approach — don't retry the same call, don't attempt to bypass the restriction."; + +interface ContextOptions { + readonly display?: ToolInputDisplay; + readonly description?: string; + readonly approvalRule?: string; + readonly traceId?: string; + readonly signal?: AbortSignal; +} + +function makeContext( + toolName: string, + args: Record = {}, + options: ContextOptions = {}, +): ResolvedToolExecutionHookContext { + const toolCall: ToolCall = { + type: 'function', + id: `call-${toolName}`, + name: toolName, + arguments: JSON.stringify(args), + }; + return { + turnId: 1, + signal: options.signal ?? new AbortController().signal, + trace: options.traceId === undefined ? undefined : { traceId: options.traceId }, + toolCall, + toolCalls: [toolCall], + args, + execution: { + description: options.description ?? `Approve ${toolName}`, + display: options.display, + approvalRule: options.approvalRule ?? toolName, + execute: () => Promise.resolve({ output: '' }), + }, + }; +} + +function ask( + overrides: Partial> = {}, +): Extract { + return { kind: 'ask', ...overrides }; +} + +describe('AgentToolApprovalService', () => { + let disposables: DisposableStore; + let ix: TestInstantiationService; + let mode: PermissionMode; + let records: TelemetryRecord[]; + let recorded: PermissionApprovalResultRecord[]; + let eventBus: IEventBus; + + beforeEach(() => { + disposables = new DisposableStore(); + eventBus = disposables.add(new EventBusService()); + mode = 'manual'; + records = []; + recorded = []; + ix = createServices(disposables, { + additionalServices: (reg) => { + reg.defineInstance( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'main', agentScope: 'main' }), + ); + reg.defineInstance(IAgentPermissionModeService, stubPermissionModeService(() => mode)); + reg.defineInstance(IAgentPermissionRulesService, { + _serviceBrand: undefined, + rules: [], + sessionApprovalRulePatterns: [], + addRules: () => {}, + recordApprovalResult: (record) => { + recorded.push(record); + }, + }); + reg.defineInstance(ISessionContext, makeSessionContext({ + sessionId: 'test-session', + workspaceId: 'test-workspace', + sessionDir: '/tmp/test-session', + sessionScope: 'sessions/test-workspace/test-session', + metaScope: 'sessions/test-workspace/test-session/session-meta', + cwd: '/tmp/test-session', + })); + reg.defineInstance(ITelemetryService, recordingTelemetry(records)); + reg.defineInstance(IEventBus, eventBus); + reg.define(IAgentToolApprovalService, AgentToolApprovalService); + }, + strict: true, + }); + }); + afterEach(() => { + disposables.dispose(); + }); + + function make(): IAgentToolApprovalService { + return ix.get(IAgentToolApprovalService); + } + + function useBroker( + request: (approval: ApprovalRequest) => Promise, + ): ReturnType Promise>> { + const requestSpy = vi.fn(request); + ix.set(ISessionApprovalService, { + _serviceBrand: undefined, + request: requestSpy, + enqueue: (approval) => ({ ...approval, id: approval.id ?? 'approval-1' }), + decide: () => {}, + listPending: () => [], + }); + return requestSpy; + } + + function subscribeApprovalEvents(): { + readonly requested: ReturnType; + readonly resolved: ReturnType; + } { + const requested = vi.fn(); + const resolved = vi.fn(); + disposables.add(eventBus.subscribe('permission.approval.requested', requested)); + disposables.add(eventBus.subscribe('permission.approval.resolved', resolved)); + return { requested, resolved }; + } + + function useSubagentScope(): void { + ix.set( + IAgentScopeContext, + makeAgentScopeContext({ agentId: 'sub-1', agentScope: 'sub-1' }), + ); + } + + describe('resolvePermissionResolution', () => { + it('maps an approve without metadata to undefined', async () => { + const svc = make(); + await expect( + svc.resolvePermissionResolution({ kind: 'approve' }, makeContext('Bash'), 'p'), + ).resolves.toBeUndefined(); + }); + + it('passes executionMetadata through on approve', async () => { + const executionMetadata = { marker: true }; + const svc = make(); + await expect( + svc.resolvePermissionResolution( + { kind: 'approve', executionMetadata }, + makeContext('Bash'), + 'p', + ), + ).resolves.toEqual({ executionMetadata }); + }); + + it('maps a deny to a block with the policy message', async () => { + const svc = make(); + await expect( + svc.resolvePermissionResolution( + { kind: 'deny', message: 'nope' }, + makeContext('Bash'), + 'p', + ), + ).resolves.toEqual({ block: true, reason: 'nope' }); + }); + + it('uses a default reason when a deny has no message', async () => { + const svc = make(); + await expect( + svc.resolvePermissionResolution({ kind: 'deny' }, makeContext('Bash'), 'p'), + ).resolves.toEqual({ + block: true, + reason: 'Tool "Bash" was denied by permission policy.', + }); + }); + + it('appends worker guidance to deny messages for subagents', async () => { + useSubagentScope(); + const svc = make(); + await expect( + svc.resolvePermissionResolution( + { kind: 'deny', message: 'nope' }, + makeContext('Bash'), + 'p', + ), + ).resolves.toEqual({ + block: true, + reason: `nope ${RETRY_GUIDANCE}`, + }); + }); + + it('strips the kind marker from result resolutions', async () => { + const svc = make(); + await expect( + svc.resolvePermissionResolution( + { + kind: 'result', + syntheticResult: { output: 'Plan review handled.' }, + updatedArgs: { extra: true }, + }, + makeContext('ExitPlanMode'), + 'p', + ), + ).resolves.toEqual({ + syntheticResult: { output: 'Plan review handled.' }, + updatedArgs: { extra: true }, + }); + }); + + it('runs the ask round-trip for ask resolutions', async () => { + useBroker(async () => ({ decision: 'approved' })); + const svc = make(); + await expect( + svc.resolvePermissionResolution(ask(), makeContext('Bash'), 'p'), + ).resolves.toBeUndefined(); + }); + }); + + describe('requestToolApproval', () => { + it('auto-approves when no approval broker is registered', async () => { + const events = subscribeApprovalEvents(); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Bash', { command: 'printf hi' }), ask(), 'fallback-ask'), + ).resolves.toBeUndefined(); + + expect(events.requested).not.toHaveBeenCalled(); + expect(events.resolved).not.toHaveBeenCalled(); + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + toolName: 'Bash', + sessionApprovalRule: undefined, + result: { decision: 'approved' }, + }); + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + policy_name: 'fallback-ask', + tool_name: 'Bash', + result: 'approved', + session_cache_written: false, + }), + }); + }); + + it('publishes approval events around the broker round-trip', async () => { + const events = subscribeApprovalEvents(); + const request = useBroker(async () => ({ + decision: 'approved', + selectedLabel: 'Approve once', + })); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Bash', { command: 'printf first' }), ask(), 'fallback-ask'), + ).resolves.toBeUndefined(); + + expect(request).toHaveBeenCalledTimes(1); + expect(events.requested).toHaveBeenCalledWith({ + type: 'permission.approval.requested', + sessionId: 'test-session', + agentId: 'main', + turnId: 1, + toolCallId: 'call-Bash', + toolName: 'Bash', + action: 'Approve Bash', + toolInput: { command: 'printf first' }, + display: { + kind: 'generic', + summary: 'Approve Bash', + detail: { command: 'printf first' }, + }, + }); + expect(events.resolved).toHaveBeenCalledWith({ + type: 'permission.approval.resolved', + sessionId: 'test-session', + agentId: 'main', + turnId: 1, + toolCallId: 'call-Bash', + toolName: 'Bash', + action: 'Approve Bash', + toolInput: { command: 'printf first' }, + display: { + kind: 'generic', + summary: 'Approve Bash', + detail: { command: 'printf first' }, + }, + decision: 'approved', + selectedLabel: 'Approve once', + }); + }); + + it('uses the execution description and display when provided', async () => { + const request = useBroker(async () => ({ decision: 'approved' })); + const svc = make(); + const display: ToolInputDisplay = { kind: 'command', command: 'rm -rf build' }; + + await svc.requestToolApproval( + makeContext( + 'Bash', + { command: 'rm -rf build' }, + { description: 'clean build output', display }, + ), + ask(), + 'fallback-ask', + ); + + expect(request).toHaveBeenCalledWith({ + sessionId: 'test-session', + agentId: 'main', + turnId: 1, + toolCallId: 'call-Bash', + toolName: 'Bash', + action: 'clean build output', + display, + }); + }); + + it('records a session-scope approval rule when approved for session', async () => { + useBroker(async () => ({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + })); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Custom', { query: 'first' }), ask(), 'fallback-ask'), + ).resolves.toBeUndefined(); + + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + turnId: 1, + toolCallId: 'call-Custom', + toolName: 'Custom', + action: 'Approve Custom', + sessionApprovalRule: 'Custom', + result: { decision: 'approved', scope: 'session' }, + }); + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + tool_name: 'Custom', + result: 'approved_for_session', + session_cache_written: true, + }), + }); + }); + + it('keeps approved-once responses out of the session cache', async () => { + useBroker(async () => ({ decision: 'approved' })); + const svc = make(); + + await svc.requestToolApproval(makeContext('Custom'), ask(), 'fallback-ask'); + + expect(recorded).toHaveLength(1); + expect(recorded[0]).toMatchObject({ + sessionApprovalRule: undefined, + result: { decision: 'approved' }, + }); + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + result: 'approved', + session_cache_written: false, + }), + }); + }); + + it('maps a rejected response to a block', async () => { + useBroker(async () => ({ decision: 'rejected' })); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Bash'), ask(), 'fallback-ask'), + ).resolves.toEqual({ + block: true, + reason: 'Tool "Bash" was not run because the user rejected the approval request.', + }); + }); + + it('appends worker guidance to rejection messages for subagents', async () => { + useSubagentScope(); + useBroker(async () => ({ decision: 'rejected', feedback: 'too broad' })); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Bash'), ask(), 'fallback-ask'), + ).resolves.toEqual({ + block: true, + reason: + 'Tool "Bash" was not run because the user rejected the approval request.' + + ` Reason: too broad ${RETRY_GUIDANCE}`, + }); + }); + + it('tracks cancelled approval requests', async () => { + useBroker(async () => ({ decision: 'cancelled', feedback: 'request closed' })); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('Bash'), ask(), 'fallback-ask'), + ).resolves.toMatchObject({ + block: true, + reason: expect.stringContaining('approval request was cancelled'), + }); + + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + policy_name: 'fallback-ask', + tool_name: 'Bash', + permission_mode: 'manual', + result: 'cancelled', + has_feedback: true, + session_cache_written: false, + }), + }); + }); + + it.each([ + ['rejected', { decision: 'rejected' }, 'rejected', false], + ['cancelled', { decision: 'cancelled' }, 'cancelled', false], + [ + 'revise feedback', + { decision: 'rejected', selectedLabel: 'Revise', feedback: 'Add verification.' }, + 'rejected', + true, + ], + ] as const)( + 'tracks ask continuation telemetry for %s', + async (_name, response, expectedResult, expectedHasFeedback) => { + useBroker(async () => response); + const svc = make(); + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: '# Plan', + path: '/tmp/kimi-plan.md', + }; + + await expect( + svc.requestToolApproval( + makeContext('ExitPlanMode', {}, { display }), + ask({ + resolveApproval: () => ({ + kind: 'result', + syntheticResult: { output: 'Plan review handled.' }, + }), + }), + 'exit-plan-mode-review-ask', + ), + ).resolves.toEqual({ + syntheticResult: { output: 'Plan review handled.' }, + }); + + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + policy_name: 'exit-plan-mode-review-ask', + tool_name: 'ExitPlanMode', + permission_mode: 'manual', + result: expectedResult, + approval_surface: 'plan_review', + duration_ms: expect.any(Number), + session_cache_written: false, + has_feedback: expectedHasFeedback, + }), + }); + }, + ); + + it('tracks approval transport errors before rethrowing', async () => { + const events = subscribeApprovalEvents(); + const error = new Error('approval transport closed'); + useBroker(async () => { + throw error; + }); + const svc = make(); + + await expect( + svc.requestToolApproval(makeContext('ExitPlanMode'), ask(), 'exit-plan-mode-review-ask'), + ).rejects.toThrow('approval transport closed'); + + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + policy_name: 'exit-plan-mode-review-ask', + tool_name: 'ExitPlanMode', + result: 'error', + }), + }); + expect(events.resolved).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'permission.approval.resolved', + decision: 'error', + error: 'approval transport closed', + }), + ); + }); + + it('folds resolveError continuations into the result instead of rethrowing', async () => { + useBroker(async () => { + throw new Error('approval transport closed'); + }); + const svc = make(); + + await expect( + svc.requestToolApproval( + makeContext('ExitPlanMode'), + ask({ resolveError: () => ({ kind: 'deny', message: 'review unavailable' }) }), + 'exit-plan-mode-review-ask', + ), + ).resolves.toEqual({ + block: true, + reason: 'review unavailable', + }); + }); + + it('rethrows user cancellations without telemetry or resolution events', async () => { + const events = subscribeApprovalEvents(); + const controller = new AbortController(); + useBroker(() => new Promise(() => {})); + const svc = make(); + + const promise = svc.requestToolApproval( + makeContext('Bash', {}, { signal: controller.signal }), + ask(), + 'fallback-ask', + ); + const expectation = expect(promise).rejects.toBeInstanceOf(UserCancellationError); + controller.abort(new UserCancellationError()); + await expectation; + + expect(events.requested).toHaveBeenCalledTimes(1); + expect(events.resolved).not.toHaveBeenCalled(); + expect(records).toEqual([]); + expect(recorded).toEqual([]); + }); + + it('merges the request trace id into approval result telemetry', async () => { + useBroker(async () => ({ decision: 'approved' })); + const svc = make(); + + await svc.requestToolApproval( + makeContext('bash', {}, { traceId: 'trace-approval-1' }), + ask(), + 'fallback-ask', + ); + + expect(records).toContainEqual({ + event: 'permission_approval_result', + properties: expect.objectContaining({ + tool_name: 'bash', + result: 'approved', + trace_id: 'trace-approval-1', + }), + }); + }); + }); + + describe('message formatting', () => { + it('keeps deny messages plain for the main agent', () => { + const svc = make(); + expect(svc.formatDenyMessage('nope')).toBe('nope'); + }); + + it('appends worker guidance to deny messages for subagents', () => { + useSubagentScope(); + const svc = make(); + expect(svc.formatDenyMessage('nope')).toBe(`nope ${RETRY_GUIDANCE}`); + }); + + it('includes feedback in rejection messages', () => { + const svc = make(); + expect( + svc.formatApprovalRejectionMessage('Bash', { + decision: 'rejected', + feedback: 'too broad', + }), + ).toBe( + 'Tool "Bash" was not run because the user rejected the approval request. Reason: too broad', + ); + }); + + it('uses the cancelled prefix for cancellations', () => { + const svc = make(); + expect(svc.formatApprovalRejectionMessage('Bash', { decision: 'cancelled' })).toBe( + 'Tool "Bash" was not run because the approval request was cancelled.', + ); + }); + }); +}); diff --git a/packages/agent-core-v2/test/session/btw/btw.test.ts b/packages/agent-core-v2/test/session/btw/btw.test.ts index 4873ceb5a1..ac74b23757 100644 --- a/packages/agent-core-v2/test/session/btw/btw.test.ts +++ b/packages/agent-core-v2/test/session/btw/btw.test.ts @@ -3,11 +3,21 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SyncDescriptor } from '#/_base/di/descriptors'; import { DisposableStore } from '#/_base/di/lifecycle'; import { TestInstantiationService } from '#/_base/di/test'; -import { IAgentPermissionPolicyService } from '#/agent/permissionPolicy/permissionPolicy'; -import { DenyAllPermissionPolicyService } from '#/agent/permissionPolicy/policies/deny-all'; import { IAgentSystemReminderService } from '#/agent/systemReminder/systemReminder'; +import { IAgentToolApprovalService } from '#/agent/toolApproval/toolApproval'; +import { IAgentToolExecutorService } from '#/agent/toolExecutor/toolExecutor'; +import type { + ToolBeforeExecuteContext, + ToolDidExecuteContext, +} from '#/agent/toolExecutor/toolHooks'; +import { OrderedHookSlot } from '#/hooks'; +import type { ToolCall } from '#/kosong/contract/message'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; -import { ISessionBtwService, SIDE_QUESTION_SYSTEM_REMINDER } from '#/session/btw/btw'; +import { + ISessionBtwService, + SIDE_QUESTION_SYSTEM_REMINDER, + TOOL_CALL_DISABLED_MESSAGE, +} from '#/session/btw/btw'; import { SessionBtwService } from '#/session/btw/btwService'; describe('SessionBtwService', () => { @@ -15,20 +25,32 @@ describe('SessionBtwService', () => { let ix: TestInstantiationService; let fork: ReturnType; let appendSystemReminder: ReturnType; - let registerPolicy: ReturnType; + let formatDenyMessage: ReturnType; + let beforeExecuteSlot: OrderedHookSlot; beforeEach(() => { disposables = new DisposableStore(); ix = disposables.add(new TestInstantiationService()); appendSystemReminder = vi.fn(); - registerPolicy = vi.fn(); + // The suffix mimics the worker-rejection guidance formatDenyMessage appends + // for forked sub agents, so the assertion proves the reason went through it. + formatDenyMessage = vi.fn((message: string) => `${message} [worker guidance]`); + beforeExecuteSlot = new OrderedHookSlot(); const child = { id: 'agent-btw-1', accessor: { get: (id: unknown) => { if (id === IAgentSystemReminderService) return { appendSystemReminder }; - if (id === IAgentPermissionPolicyService) return { registerPolicy }; + if (id === IAgentToolApprovalService) return { formatDenyMessage }; + if (id === IAgentToolExecutorService) { + return { + hooks: { + onBeforeExecuteTool: beforeExecuteSlot, + onDidExecuteTool: new OrderedHookSlot(), + }, + }; + } return undefined; }, }, @@ -52,7 +74,31 @@ describe('SessionBtwService', () => { kind: 'system_trigger', name: 'btw', }); - expect(registerPolicy).toHaveBeenCalledTimes(1); - expect(registerPolicy.mock.calls[0]![0]).toBeInstanceOf(DenyAllPermissionPolicyService); + }); + + it('blocks every tool call on the child through the btw-deny-all executor hook', async () => { + const svc = ix.get(ISessionBtwService); + await svc.start(); + + const toolCall: ToolCall = { type: 'function', id: 'call_1', name: 'Bash', arguments: '{}' }; + const ctx: ToolBeforeExecuteContext = { + turnId: 0, + signal: new AbortController().signal, + toolCall, + toolCalls: [toolCall], + args: {}, + execution: { approvalRule: 'Bash', execute: async () => ({ output: '' }) }, + }; + let terminalRan = false; + await beforeExecuteSlot.run(ctx, async () => { + terminalRan = true; + }); + + expect(ctx.decision).toEqual({ + block: true, + reason: `${TOOL_CALL_DISABLED_MESSAGE} [worker guidance]`, + }); + expect(terminalRan).toBe(false); + expect(formatDenyMessage).toHaveBeenCalledWith(TOOL_CALL_DISABLED_MESSAGE); }); }); From ffbe67f929c1daf9209f63e8e95ecf0fa1e1ec71 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 21 Jul 2026 15:03:17 +0800 Subject: [PATCH 02/17] feat(kimi-inspect): add App Services view for app-scope service reflection - add `services` top-level view (`AppServicesView`) on the NavRail, showing the full-width app-scope Service panel grid; session/agent scopes stay in the Chat view's Inspector - extract shared `ServicePanels` from Inspector and add `methodArgs` helper to build per-method argument editors from channel metadata - support variadic service calls in `panels.ts` (`call(svc, method, ...args)`) - update agent-core-dev skill docs for the toolApproval extraction and the guard/review-off-chain permission design --- .agents/skills/agent-core-dev/SKILL.md | 2 +- .agents/skills/agent-core-dev/permission.md | 56 +- apps/kimi-inspect/src/App.tsx | 8 +- .../src/components/AppServicesView.tsx | 27 + .../kimi-inspect/src/components/Inspector.tsx | 337 +------- apps/kimi-inspect/src/components/NavRail.tsx | 14 +- .../src/components/ServicePanels.tsx | 792 ++++++++++++++++++ .../src/components/methodArgs.test.ts | 124 +++ .../kimi-inspect/src/components/methodArgs.ts | 198 +++++ apps/kimi-inspect/src/panels.ts | 6 +- 10 files changed, 1221 insertions(+), 343 deletions(-) create mode 100644 apps/kimi-inspect/src/components/AppServicesView.tsx create mode 100644 apps/kimi-inspect/src/components/ServicePanels.tsx create mode 100644 apps/kimi-inspect/src/components/methodArgs.test.ts create mode 100644 apps/kimi-inspect/src/components/methodArgs.ts diff --git a/.agents/skills/agent-core-dev/SKILL.md b/.agents/skills/agent-core-dev/SKILL.md index d0f24b29c4..69a4458bd6 100644 --- a/.agents/skills/agent-core-dev/SKILL.md +++ b/.agents/skills/agent-core-dev/SKILL.md @@ -43,7 +43,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s - Topic: [Config](config.md) — the section-registry model, App vs Session split, owning a config section, the TOML format, and the env overlay. - Topic: [Errors](errors.md) — co-located `XxxError`, the central code registry, wire serialization, boundary translation. - Topic: [Flags](flags.md) — `registerFlagDefinition`, `IFlagService.enabled(id)`, the `[experimental]` config section, resolution precedence. - - Topic: [Permission](permission.md) — composable chain-of-responsibility kernel, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. + - Topic: [Permission](permission.md) — risk-only chain-of-responsibility kernel, harness constraints and product reviews as domain executor hooks ordered `before: 'permission'`, shared `toolApproval` round-trip, policy registry + composer, `modes`/`agentTypes` metadata, `resolveExecution`/`accesses`. - Topic: [Telemetry](telemetry.md) — emitting events via `ITelemetryService`, context propagation, and appender destinations (`ConsoleAppender` / `CloudAppender`). - [Stage 4 — Test](test.md): resolve the system under test by interface, pick `TestInstantiationService` vs `createScopedTestHost`, shared stubs, service groups, teardown. - [Stage 5 — Verify & submit](verify.md): `lint:domain`, `typecheck`, `test`, and the pre-submit checklist. diff --git a/.agents/skills/agent-core-dev/permission.md b/.agents/skills/agent-core-dev/permission.md index 7db146b9ce..4263f83e2f 100644 --- a/.agents/skills/agent-core-dev/permission.md +++ b/.agents/skills/agent-core-dev/permission.md @@ -4,6 +4,8 @@ The target design for the agent-core permission system. Read this when touching > **The permission system should be a composable, registrable chain of responsibility (a microkernel).** The kernel only runs the chain in order, first hit wins; concrete permission dimensions (policies) are contributed by their owning Domain Services through a registry; tools only declare standardized resource access (`accesses`) in `resolveExecution`, and generic dimensions consume that metadata. > +> **The chain adjudicates risk only.** A policy node answers "how dangerous is this call, and may the user override that judgment?" — its `ask`/`deny` outcomes are always user-overridable. **Harness constraints are not permissions**: a mechanism that limits the agent for its own correctness (plan-mode write guard, AgentSwarm batch exclusivity, btw side-question fork, goal budget rejection) produces a hard deny with no ask channel and no per-call user exemption. Those live in their owning domains as `onBeforeExecuteTool` hooks ordered `before: 'permission'` (precedent: `goalService.ts`'s `goal-budget-reject`). Product reviews (plan review, goal-start review) are likewise not permissions: the owning domain intercepts its tool and drives the shared `IAgentToolApprovalService` round-trip itself. +> > **Do not introduce Casbin** — the hard part here is *decision behavior* (continuations, side effects, RPC, state machines), not "match + scalar decision". ## 1. Problem definition @@ -56,7 +58,7 @@ Casbin = single Strategy + data-driven. This design = multiple Strategies + chai 1. **The chain encodes "permission dimensions", not "tools".** Adding a tool does not lengthen the chain; only adding a dimension adds a node. 2. **Two contribution paths:** high-frequency trivial specifics go through the **data path** (rules); low-frequency new dimensions with behavior go through the **code path** (policies). -3. **Domain self-registration:** a domain that owns a dimension (plan/goal/swarm) registers its policy in DI, mirroring v2's existing "domain self-registers tools". +3. **Guard/review off-chain, risk on-chain:** harness constraints and product reviews ship with their owning domain as executor hooks (§5.4); risk dimensions contributed by a domain self-register as chain policies in DI, mirroring v2's "domain self-registers tools". 4. **Tools declare resources; generic dimensions consume them:** bash/write/read only declare `accesses`; file/security dimensions judge centrally. ### 5.2 Core abstractions @@ -106,23 +108,23 @@ Key points: Most growth goes through the data path — node count is bounded by "kinds of behavior"; rule count grows with specifics (rule matching is a cheap Set/glob). -### 5.4 Domain self-registration +### 5.4 Domain dimensions: guard/review via executor hooks, policy registration for risk -Mirrors v2's "domain registers tools in its constructor". `PlanService` self-registers its dimensions: +**Harness constraints and product reviews no longer live on the chain.** A domain that owns one registers an executor hook ordered `before: 'permission'` and decides for itself: ```ts -// src/plan/planService.ts -constructor(@IPermissionPolicyRegistry registry: IPermissionPolicyRegistry) { - registry.register({ name: 'plan-mode-guard-deny', phase: 'guard', - factory: a => new PlanModeGuardDenyPolicy(a.get(IPlanService)) }); - registry.register({ name: 'plan-mode-tool-approve', phase: 'mode', - factory: a => new PlanModeToolApprovePolicy(a.get(IPlanService)) }); - registry.register({ name: 'exit-plan-mode-review-ask', phase: 'user-ask', - factory: a => new ExitPlanModeReviewAskPolicy(a.get(IPlanService), a.get(IPermissionModeService)) }); +// src/plan/planService.ts — constructor +constructor(@IAgentPermissionGate _gate, @IAgentToolExecutorService executor, ...) { + executor.hooks.onBeforeExecuteTool.register('plan-guard', handler, { before: 'permission' }); } ``` -A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. +- Injecting `IAgentPermissionGate` (unused, `_`-prefixed) forces the gate to be constructed first, so the `'permission'` hook target always exists — the built-in tools registrar can otherwise construct the domain service before the gate is ignited. +- **Guard** (hard deny): set `ctx.decision = { block: true, reason: toolApproval.formatDenyMessage(...) }` and do not call `next()`. Short-circuiting a *deny* ahead of the chain is correct — these policies sat at the head of the chain anyway. +- **Review** (product approval): intercept the tool, drive `IAgentToolApprovalService.requestToolApproval(ctx, ask, origin)` and fold the continuation into `ctx.decision`; pass `next()` for every case you do not review so user rules still apply. +- **Plain allow**: do NOT short-circuit approvals in the hook — put the tool in `default-tool-approve`'s whitelist and `next()`, so user deny/ask rules keep their precedence. + +**Risk dimensions contributed by a domain still go through the chain** (the registry path below): a domain whose state changes the *risk* verdict registers its policy via `IPermissionPolicyRegistry`, mirroring v2's "domain self-registers tools". A complex domain may register a single **composite** node externally and run a small internal chain, hiding its internal order from the global chain. ### 5.5 Tools declare resources at runtime (`resolveExecution` / `accesses`) @@ -170,37 +172,43 @@ Each new resource kind can pair with a generic dimension that consumes it; tools ### 5.6 Dimension ownership -| Dimension | Owner (who registers) | Type | +| Dimension | Owner | Type | |---|---|---| | external hook veto | `externalHooks` domain | generic | -| tool-batch exclusivity | `swarm` domain | domain-specific (ships with the AgentSwarm tool) | -| runtime-mode posture | `permissionMode` domain | generic | -| plan-mode constraints | `plan` domain | domain-specific | -| goal-start approval | `goal` domain | domain-specific | +| tool-batch exclusivity | `swarm` domain — executor hook `swarm-exclusive` | harness constraint (off-chain) | +| plan-mode write guard | `plan` domain — executor hook `plan-guard` | harness constraint (off-chain) | +| plan review | `plan` domain — same hook + `toolApproval` | product review (off-chain) | +| goal-start review | `goal` domain — executor hook `goal-start-review` + `toolApproval` | product review (off-chain) | +| goal budget rejection | `goal` domain — executor hook `goal-budget-reject` | harness constraint (off-chain) | +| btw tool disablement | `btw` domain — executor hook `btw-deny-all` on the fork | harness constraint (off-chain) | +| runtime-mode posture (auto/yolo) | `permissionMode` domain (chain nodes, pending the level×routing split) | generic | | static config rules | `permissionRules` domain | generic (data path) | | session approval memory | `permissionRules` domain | generic | | sensitive / special paths | generic "file-access/security" dimension | generic (consumes `accesses`) | -| tool intrinsic risk | core permission | generic (consumes tool declarations) | +| tool intrinsic risk | core permission (`default-tool-approve`) | generic (consumes tool declarations) | | workspace write trust | generic "file-access/security" dimension | generic (consumes `accesses`) | | fallback | core permission | generic | +| approval round-trip | `toolApproval` domain — shared by gate asks and domain reviews | infrastructure | -Pattern: **specific dimensions ship with their owning domain + tool; generic dimensions register centrally and apply across tools via the declared `accesses`.** +Pattern: **harness constraints and reviews ship with their owning domain as executor hooks; risk dimensions ship as chain policies (self-registered once the registry lands); generic dimensions register centrally and apply across tools via the declared `accesses`.** ## 6. Evolution path Incremental, not big-bang: -1. **Registry + Composer (zero behavior change).** Replace the 19 hardcoded `new`s in v2 `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; register existing policies as-is. Immediately gain multi-agent/mode selectable chains and an external registration entry. -2. **Declarative modes.** Lift the mode guards in `YoloModeApprove` / `AutoModeApprove` into `modes` metadata. -3. **Sink domain dimensions.** Move registration of plan/goal/swarm policies into their owning domain service constructors. +1. ~~**Sink domain dimensions.**~~ **Done** — plan guard/review, goal-start review, swarm batch exclusivity, and btw deny-all moved out of the chain into their owning domains as executor hooks ordered `before: 'permission'`; the shared approval round-trip was extracted to `IAgentToolApprovalService`; `registerPolicy` was removed (btw was its only production user). The chain now holds 12 risk-adjudication nodes only. +2. **Level × routing split.** Separate "risk level" (read-only / read-write / yolo posture — what `yolo-mode-approve` really is) from "interaction routing" (what `auto-mode-approve` / `auto-mode-ask-user-question-deny` really are: route permission asks and reviews without the user). The routing layer lands on the `session/approval` broker; the three remaining mode policies leave the chain here. +3. **Registry + Composer.** Replace the hardcoded `new`s in `PermissionPolicyService` with reads from `IPermissionPolicyRegistry`; lift mode guards into `modes` metadata. Chain shape becomes selectable per `(agent, mode)` and externally extensible. 4. **(On demand) extend resource types.** When non-file resources (network/DB/shell) need structural dimensions, extend the `ToolResourceAccess` union. 5. **(On demand) swap the matching kernel for Casbin.** Only when external rules genuinely need RBAC/ABAC semantics, swap the data-path rule-matching kernel for Casbin. Not before. ## Red lines (this topic) - Do not introduce Casbin — decisions are behavior bundles, not scalar effects. +- The chain adjudicates risk only. A node whose deny/ask the user cannot per-call exempt is a harness constraint: implement it as an executor hook ordered `before: 'permission'` in the owning domain, never as a chain policy. +- Product reviews (plan/goal) are not permissions either: the owning domain intercepts its tool and drives `IAgentToolApprovalService` itself; the gate only handles chain asks. +- When registering such a hook, inject `IAgentPermissionGate` (unused) so the gate exists before you order `before: 'permission'`. - The chain encodes dimensions, not tools: a new tool must not lengthen the chain. -- New specifics go through the data path (rules); only new behavior goes through the code path (a policy node). -- A domain that owns a dimension self-registers its policy in DI; do not centralize domain policies in core. +- New specifics go through the data path (rules); only new risk behavior goes through the code path (a policy node). - Tools only declare `accesses`; generic dimensions consume them. kaos is the execution environment, not the permission abstraction. - Use `factory` (Agent-scope instantiation), not `instance`, for registered policies. diff --git a/apps/kimi-inspect/src/App.tsx b/apps/kimi-inspect/src/App.tsx index 3d03745c62..bc0ff4f7a8 100644 --- a/apps/kimi-inspect/src/App.tsx +++ b/apps/kimi-inspect/src/App.tsx @@ -5,13 +5,15 @@ * interactions card fetch on demand and the sidebar polls. * Layout: header / icon rail / view. The `chat` view is the classic trio * (left sidebar with workspaces + sessions, chat, inspector); the `models` - * view is the full-width model catalog. + * view is the full-width model catalog; the `services` view is the + * full-width app-scope Service reflection (`AppServicesView`). */ import { useEffect, useState } from 'react'; import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { AppServicesView } from './components/AppServicesView'; import { ChatView } from './components/ChatView'; import { Inspector } from './components/Inspector'; import { ModelCatalogView } from './components/ModelCatalogView'; @@ -72,7 +74,9 @@ export function App() {
- {view === 'models' ? ( + {view === 'services' ? ( + + ) : view === 'models' ? ( { setSessionId(id); diff --git a/apps/kimi-inspect/src/components/AppServicesView.tsx b/apps/kimi-inspect/src/components/AppServicesView.tsx new file mode 100644 index 0000000000..98ae9aa340 --- /dev/null +++ b/apps/kimi-inspect/src/components/AppServicesView.tsx @@ -0,0 +1,27 @@ +/** + * App Services view — the app-scope (server-level) Service reflection as a + * standalone rail view. Postman-style three-pane layout + * (`ScopePanelsScrollspy`): the Service list on the left, every Service's + * methods expanded in one continuously scrolling column in the middle + * (scroll position and left-side highlight kept in sync), and a + * request/response call history on the right. The proxies resolve on the + * `core` route, so this view works before any session is selected. + */ + +import { useCallback } from 'react'; + +import { serviceByName } from '../channel'; +import { useConnection } from '../connection'; +import type { AnyService } from '../panels'; +import { ScopePanelsScrollspy } from './ServicePanels'; + +export function AppServicesView() { + const { klient } = useConnection(); + const proxyFor = useCallback( + (name: string): AnyService | null => + serviceByName(klient, name, { scope: 'app' }) ?? null, + [klient], + ); + + return ; +} diff --git a/apps/kimi-inspect/src/components/Inspector.tsx b/apps/kimi-inspect/src/components/Inspector.tsx index 86b0b374d4..54ccc8983b 100644 --- a/apps/kimi-inspect/src/components/Inspector.tsx +++ b/apps/kimi-inspect/src/components/Inspector.tsx @@ -1,13 +1,9 @@ /** - * Right sidebar — access point for the Services of the server (app scope), - * the active session, and the active agent. Hosts the agent switcher, three - * tabs (app / session / agent), the pending-interaction card, and the - * Service panels. - * - * The panel list is dynamic: `GET /api/v1/debug/channels` describes every - * wire-exposed Service with its methods, rendered by `DynamicServiceCard`; - * the handwritten descriptors in `panels.ts` override individual Services - * with curated cards (`ServiceCard`). + * Right sidebar — access point for the Services of the active session and + * the active agent. Hosts the agent switcher, two tabs (session / agent), + * the pending-interaction card, and the Service panels (`ScopePanels`). + * The app-scope (server-level) Services live in their own rail view + * (`AppServicesView`), not here. * * Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event * socket (`/api/v2/ws`) that used to push core/session/agent event streams @@ -23,38 +19,13 @@ import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMeta import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; -import { - fetchChannelDescriptors, - serviceByName, - type ChannelDescriptor, -} from '../channel'; +import { serviceByName } from '../channel'; import { useConnection } from '../connection'; -import { - AGENT_PANELS, - CORE_PANELS, - SESSION_PANELS, - call, - type AnyService, - type ServicePanelDef, -} from '../panels'; +import { type AnyService } from '../panels'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; +import { ScopePanels } from './ServicePanels'; -type Tab = 'app' | 'session' | 'agent'; -type Scope = 'app' | 'session' | 'agent'; - -const PANEL_OVERRIDES: ReadonlyMap = new Map( - [...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]), -); - -/** Load the full protocol list once per connection (every channel, 1:1). */ -function useChannels() { - const { klient } = useConnection(); - return useQuery({ - queryKey: ['channels', klient.baseUrl], - queryFn: () => fetchChannelDescriptors(klient), - staleTime: Number.POSITIVE_INFINITY, - }); -} +type Tab = 'session' | 'agent'; export function Inspector({ sessionId, @@ -69,7 +40,6 @@ export function Inspector({ }) { const { klient } = useConnection(); const [tab, setTab] = useState('session'); - const channels = useChannels(); const meta = useQuery({ queryKey: ['sessionMeta', sessionId], @@ -104,75 +74,18 @@ export function Inspector({ } }; - // Resolve a Service proxy by channel name + scope, 1:1 with the channel - // descriptor from `/api/v1/debug/channels`. Returns null when the scope - // needs a session that isn't selected/ready. - const serviceProxy = useMemo(() => { - return (name: string, scope: Scope): AnyService | null => { + // Resolve a Service proxy by channel name, 1:1 with the channel descriptor + // from `/api/v1/debug/channels`. Returns null when the scope needs a + // session that isn't selected/ready. + const proxyFor = useMemo(() => { + return (name: string): AnyService | null => { return serviceByName(klient, name, { - scope, + scope: tab, sessionId: sessionId !== null && ready ? sessionId : undefined, agentId: effectiveAgent, }) ?? null; }; - }, [klient, sessionId, effectiveAgent, ready]); - - // Panels for one scope: the dynamic channel list merged with the handwritten - // overrides. When the channels endpoint is unavailable, fall back to the - // handwritten panels only. - const renderPanels = (scope: Scope) => { - const byName = new Map(); - if (channels.data !== undefined) { - for (const c of channels.data) { - if (c.scope === scope) byName.set(c.name, c); - } - // Keep overrides the introspection missed (e.g. server drift). - for (const def of PANEL_OVERRIDES.values()) { - if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined); - } - } else { - for (const def of PANEL_OVERRIDES.values()) { - if (def.scope === scope) byName.set(def.id, undefined); - } - } - const list = [...byName.entries()]; - return ( - <> - {channels.isError ? ( -
- -
- dynamic channel list unavailable — showing handwritten panels only -
-
- ) : null} - {list.map(([name, channel]) => { - const def = PANEL_OVERRIDES.get(name); - const onError = - scope === 'agent' ? (error: unknown) => noteAgentError(effectiveAgent, error) : undefined; - if (def !== undefined) { - return ( - - ); - } - if (channel === undefined) return null; - return ( - - ); - })} - - ); - }; + }, [klient, tab, sessionId, effectiveAgent, ready]); const sessionBlocked = sessionId === null || !ready; @@ -207,7 +120,7 @@ export function Inspector({ {/* Tabs */}
- {(['app', 'session', 'agent'] as const).map((t) => ( + {(['session', 'agent'] as const).map((t) => ( ))}
- {tab === 'app' ? ( - renderPanels('app') - ) : sessionBlocked ? ( + {sessionBlocked ? (
{sessionId === null ? 'No session selected.' : 'Loading session…'}
) : tab === 'session' ? ( <> - {renderPanels('session')} + ) : ( - renderPanels('agent') + noteAgentError(effectiveAgent, error)} + /> )}
); } -// --------------------------------------------------------------------------- -// Generic service card -// --------------------------------------------------------------------------- - -function ServiceCard({ - def, - svc, - onError, -}: { - def: ServicePanelDef; - svc: AnyService | null; - onError?: (error: unknown) => void; -}) { - const [data, setData] = useState(undefined); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(null); - const [loaded, setLoaded] = useState(false); - - const refresh = async () => { - if (svc === null || def.fetch === undefined) return; - try { - setError(null); - const result = await def.fetch(svc); - setData(result); - setLoaded(true); - } catch (error) { - setError(error); - onError?.(error); - } - }; - - return ( -
-
-
- {def.label} - {def.id} -
- {def.fetch !== undefined ? ( - void refresh()} disabled={svc === null}> - {loaded ? 'Refresh' : 'Load'} - - ) : null} -
-
- {error !== null ?
: null} - {def.fetch !== undefined ? ( - loaded ? ( - - ) : ( -
click Load to read this Service
- ) - ) : null} - {def.actions !== undefined && def.actions.length > 0 ? ( -
- {def.actions.map((action) => ( - { - if (svc === null) return; - let input: string | undefined; - if (action.input !== undefined) { - const raw = window.prompt(action.input); - if (raw === null) return; - input = raw; - } - setBusy(action.label); - setError(null); - try { - const result = await action.run(svc, input); - if (result !== undefined && def.fetch === undefined) setData(result); - if (def.fetch !== undefined) await refresh(); - } catch (error) { - setError(error); - onError?.(error); - } finally { - setBusy(null); - } - }} - > - {busy === action.label ? '…' : action.label} - - ))} -
- ) : null} - {def.fetch === undefined && data !== undefined ? ( -
- ) : null} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Dynamic service card — generic renderer for channels without a handwritten -// override. Every method gets a call button labeled with its declared -// signature (JSON arg input when it takes parameters); getters become read -// buttons. Results render inline. -// --------------------------------------------------------------------------- - -function DynamicServiceCard({ - channel, - svc, - onError, -}: { - channel: ChannelDescriptor; - svc: AnyService | null; - onError?: (error: unknown) => void; -}) { - const [open, setOpen] = useState(false); - const [args, setArgs] = useState>({}); - const [results, setResults] = useState>({}); - const [errors, setErrors] = useState>({}); - const [busy, setBusy] = useState(null); - - const invoke = async (method: ChannelDescriptor['methods'][number]) => { - if (svc === null) return; - let arg: unknown; - if (method.kind === 'method' && method.params !== '') { - const raw = (args[method.name] ?? '').trim(); - if (raw !== '') { - try { - arg = JSON.parse(raw); - } catch { - setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') })); - return; - } - } - } - setBusy(method.name); - setErrors((prev) => ({ ...prev, [method.name]: null })); - try { - const result = await call(svc, method.name, arg); - setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' })); - } catch (error) { - setErrors((prev) => ({ ...prev, [method.name]: error })); - onError?.(error); - } finally { - setBusy(null); - } - }; - - return ( -
-
setOpen((v) => !v)} - > -
- {channel.name} - - {channel.methods.length} methods · {channel.domain} - -
- {open ? '▾' : '▸'} -
- {open ? ( -
- {channel.methods.length === 0 ? ( -
no callable members
- ) : null} - {channel.methods.map((m) => ( -
-
- void invoke(m)} - > - {busy === m.name ? '…' : `${m.name}(${m.params})`} - - {m.kind === 'property' ? get : null} - {m.kind === 'method' && m.params !== '' ? ( - - setArgs((prev) => ({ ...prev, [m.name]: e.target.value })) - } - /> - ) : null} -
- {errors[m.name] ? ( -
- -
- ) : null} - {results[m.name] !== undefined ? ( -
- -
- ) : null} -
- ))} -
- ) : null} -
- ); -} - // --------------------------------------------------------------------------- // Pending interactions (approvals / questions) — fetched on demand: the // session `interactions` push stream went away with `/api/v2/ws`, so the diff --git a/apps/kimi-inspect/src/components/NavRail.tsx b/apps/kimi-inspect/src/components/NavRail.tsx index 7d9458412c..ff4f9495ca 100644 --- a/apps/kimi-inspect/src/components/NavRail.tsx +++ b/apps/kimi-inspect/src/components/NavRail.tsx @@ -6,7 +6,7 @@ import type { ReactNode } from 'react'; -export type AppView = 'chat' | 'models'; +export type AppView = 'chat' | 'models' | 'services'; interface ViewDef { readonly id: AppView; @@ -46,6 +46,18 @@ const VIEWS: readonly ViewDef[] = [ ), }, + { + id: 'services', + title: 'App Services', + icon: ( + + + + + + + ), + }, ]; export function NavRail({ diff --git a/apps/kimi-inspect/src/components/ServicePanels.tsx b/apps/kimi-inspect/src/components/ServicePanels.tsx new file mode 100644 index 0000000000..0a5a690824 --- /dev/null +++ b/apps/kimi-inspect/src/components/ServicePanels.tsx @@ -0,0 +1,792 @@ +/** + * Scope panels — the merged Service list for one wire scope (`app` / + * `session` / `agent`), shared by the chat inspector (session + agent tabs) + * and the standalone App Services rail view. The baseline is the dynamic + * channel list served by `GET /api/v1/debug/channels` (every wire-exposed + * Service with its methods), rendered by `DynamicServiceCard`; the + * handwritten descriptors in `panels.ts` override individual Services with + * curated cards (`ServiceCard`). + * + * Two layouts consume the same merged list (`useScopePanels`): + * - `ScopePanels`: a plain stacked card list (the chat inspector). + * - `ScopePanelsScrollspy`: a Postman-style three-pane layout — Service + * list on the left with a fuzzy filter box, every Service's card + * expanded (no collapsing) in one continuously scrolling column in the + * middle (scroll position drives the left-side highlight, clicks scroll + * the middle), and a request/response call history on the right fed by + * recording proxies around every card. Card headers copy the Service + * name on click (`CopyableName`). + * + * Everything here is fetch-on-demand (Load / Refresh buttons): the v2 event + * socket (`/api/v2/ws`) that used to push core/session/agent event streams + * was removed server-side, so there is no live push to render. + */ + +import { useQuery } from '@tanstack/react-query'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { + fetchChannelDescriptors, + type ChannelDescriptor, + type ChannelScope, +} from '../channel'; +import { useConnection } from '../connection'; +import { + AGENT_PANELS, + CORE_PANELS, + SESSION_PANELS, + call, + type AnyService, + type ServicePanelDef, +} from '../panels'; +import { ActionButton, Badge, ErrorLine, JsonView, errorMessage, relTime } from '../ui'; +import { buildArgs, fieldKey, parseParamFields, type ParamField } from './methodArgs'; + +const PANEL_OVERRIDES: ReadonlyMap = new Map( + [...CORE_PANELS, ...SESSION_PANELS, ...AGENT_PANELS].map((def) => [def.id, def]), +); + +/** One merged Service entry: the wire channel plus its curated override, if any. */ +interface ScopeEntry { + readonly name: string; + readonly channel?: ChannelDescriptor; + readonly def?: ServicePanelDef; +} + +/** Load the full protocol list once per connection (every channel, 1:1). */ +function useChannels() { + const { klient } = useConnection(); + return useQuery({ + queryKey: ['channels', klient.baseUrl], + queryFn: () => fetchChannelDescriptors(klient), + staleTime: Number.POSITIVE_INFINITY, + }); +} + +/** + * Merge the dynamic channel list with the handwritten overrides for one + * scope. When the channels endpoint is unavailable, fall back to the + * handwritten panels only. + */ +function useScopePanels(scope: ChannelScope) { + const channels = useChannels(); + const entries = useMemo(() => { + const byName = new Map(); + if (channels.data !== undefined) { + for (const c of channels.data) { + if (c.scope === scope) byName.set(c.name, c); + } + // Keep overrides the introspection missed (e.g. server drift). + for (const def of PANEL_OVERRIDES.values()) { + if (def.scope === scope && !byName.has(def.id)) byName.set(def.id, undefined); + } + } else { + for (const def of PANEL_OVERRIDES.values()) { + if (def.scope === scope) byName.set(def.id, undefined); + } + } + return [...byName.entries()].map(([name, channel]) => ({ + name, + channel, + def: PANEL_OVERRIDES.get(name), + })); + }, [channels.data, scope]); + return { entries, channels }; +} + +/** + * Render every Service of one scope as a plain stacked card list. + * `proxyFor` materializes the Service proxy for a channel name (null when + * the scope is not callable, e.g. no session selected); `onError` observes + * call failures (the agent switcher uses it to mark unloaded agents). + */ +export function ScopePanels({ + scope, + proxyFor, + onError, +}: { + readonly scope: ChannelScope; + readonly proxyFor: (name: string) => AnyService | null; + readonly onError?: (error: unknown) => void; +}) { + const { entries, channels } = useScopePanels(scope); + + return ( + <> + {channels.isError ? ( +
+ +
+ dynamic channel list unavailable — showing handwritten panels only +
+
+ ) : null} + {entries.map(({ name, channel, def }) => { + if (def !== undefined) { + return ( + + ); + } + if (channel === undefined) return null; + return ( + + ); + })} + + ); +} + +/** + * Subsequence fuzzy match (case-insensitive): `ssm` matches + * `sessionScopeMetadata`. An empty query matches everything. + */ +function fuzzyMatch(name: string, query: string): boolean { + const q = query.trim().toLowerCase(); + if (q === '') return true; + const n = name.toLowerCase(); + let i = 0; + for (const ch of n) { + if (ch === q[i]) i++; + if (i === q.length) return true; + } + return false; +} + +/** + * Three-pane scope browser: the Service list on the left (with a fuzzy + * filter box), every Service's card expanded in one continuously scrolling + * column in the middle, and a Postman-style call history on the right + * (`HistoryPane`) recording every wire request/response the cards make. + * The scroll position highlights the matching list item (and keeps it + * visible); clicking a list item smooth-scrolls the middle column to that + * Service. The list hides below the `md` breakpoint and the history below + * `lg`. + */ +export function ScopePanelsScrollspy({ + scope, + title, + proxyFor, + onError, +}: { + readonly scope: ChannelScope; + readonly title: string; + readonly proxyFor: (name: string) => AnyService | null; + readonly onError?: (error: unknown) => void; +}) { + const { entries, channels } = useScopePanels(scope); + const [active, setActive] = useState(null); + const [query, setQuery] = useState(''); + const scrollRef = useRef(null); + const sectionRefs = useRef(new Map()); + const navRefs = useRef(new Map()); + + // The filter narrows the left list only; the middle column keeps every + // card mounted so in-flight inputs and results survive a search. + const filtered = useMemo( + () => entries.filter(({ name }) => fuzzyMatch(name, query)), + [entries, query], + ); + + // Call history: every card proxy is wrapped (`makeRecordingProxy`), so + // curated and dynamic cards record uniformly without per-card plumbing. + const [records, setRecords] = useState([]); + const [expandedId, setExpandedId] = useState(null); + const recordId = useRef(0); + const record = useCallback((entry: Omit) => { + const id = ++recordId.current; + setRecords((prev) => [{ ...entry, id }, ...prev].slice(0, 100)); + // Like Postman's response pane: the newest call is always open. + setExpandedId(id); + }, []); + const recordingProxyFor = useCallback( + (name: string): AnyService | null => { + const svc = proxyFor(name); + return svc === null ? null : makeRecordingProxy(name, svc, record); + }, + [proxyFor, record], + ); + + // Highlight the section nearest the top of the scroll port. Driven by the + // scroll event (not IntersectionObserver) so tall sections and the tail of + // the list behave predictably. Section refs sit in DOM order in the map. + const syncActive = useCallback(() => { + const root = scrollRef.current; + if (root === null) return; + const rootTop = root.getBoundingClientRect().top; + let current: string | null = null; + for (const [name, el] of sectionRefs.current) { + if (el.getBoundingClientRect().top - rootTop <= 96) current = name; + else break; + } + current ??= entries[0]?.name ?? null; + setActive((prev) => (prev === current ? prev : current)); + }, [entries]); + + // Sections mount as the channel list resolves, so re-sync on list changes. + useEffect(() => { + syncActive(); + }, [syncActive]); + + // Keep the highlighted item inside the left list's viewport. + useEffect(() => { + if (active === null) return; + navRefs.current.get(active)?.scrollIntoView({ block: 'nearest' }); + }, [active]); + + const scrollTo = (name: string) => { + setActive(name); + sectionRefs.current.get(name)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( +
+ +
+ {channels.isError ? ( +
+ +
+ dynamic channel list unavailable — showing handwritten panels only +
+
+ ) : null} + {entries.map(({ name, channel, def }) => ( +
{ + if (el === null) sectionRefs.current.delete(name); + else sectionRefs.current.set(name, el); + }} + className="scroll-mt-3" + > + {def !== undefined ? ( + + ) : channel !== undefined ? ( + + ) : null} +
+ ))} +
+ setExpandedId((prev) => (prev === id ? null : id))} + onClear={() => { + setRecords([]); + setExpandedId(null); + }} + /> +
+ ); +} + +// --------------------------------------------------------------------------- +// Call history — one record per wire call, newest first, capped at 100. +// --------------------------------------------------------------------------- + +interface CallRecord { + readonly id: number; + readonly service: string; + readonly method: string; + readonly args: readonly unknown[]; + readonly at: number; + readonly durationMs: number; + readonly ok: boolean; + readonly result?: unknown; + readonly error?: string; +} + +/** + * Wrap a Service proxy so every method call is recorded (args, result or + * error, duration) before its promise settles. Errors rethrow unchanged, so + * the calling card's own error handling still runs. + */ +function makeRecordingProxy( + service: string, + svc: AnyService, + record: (entry: Omit) => void, +): AnyService { + return new Proxy(svc, { + get(target, prop) { + const member = target[prop as string]; + if (typeof member !== 'function') return member; + return (...args: unknown[]) => { + const at = Date.now(); + return Promise.resolve(member(...args)).then( + (result) => { + record({ service, method: String(prop), args, at, durationMs: Date.now() - at, ok: true, result }); + return result; + }, + (error: unknown) => { + record({ + service, + method: String(prop), + args, + at, + durationMs: Date.now() - at, + ok: false, + error: errorMessage(error), + }); + throw error; + }, + ); + }; + }, + }); +} + +function HistoryPane({ + records, + expandedId, + onToggle, + onClear, +}: { + readonly records: readonly CallRecord[]; + readonly expandedId: number | null; + readonly onToggle: (id: number) => void; + readonly onClear: () => void; +}) { + return ( +
+
+ + History {records.length > 0 ? `(${records.length})` : ''} + + {records.length > 0 ? Clear : null} +
+
+ {records.length === 0 ? ( +
+ call any method to see the request/response here +
+ ) : null} + {records.map((r) => { + const open = r.id === expandedId; + return ( +
+
onToggle(r.id)} + > + + ● + + + {r.service}.{r.method} + + {r.durationMs}ms + {relTime(r.at)} + {open ? '▾' : '▸'} +
+ {open ? ( +
+
+ Request +
+ +
+ {r.ok ? 'Response' : 'Error'} +
+ {r.ok ? ( + + ) : ( +
{r.error}
+ )} +
+ ) : null} +
+ ); + })} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Generic service card +// --------------------------------------------------------------------------- + +/** Service name that copies itself to the clipboard on click. */ +function CopyableName({ name, className }: { name: string; className?: string }) { + const [copied, setCopied] = useState(false); + return ( + + ); +} + +function ServiceCard({ + def, + svc, + onError, +}: { + def: ServicePanelDef; + svc: AnyService | null; + onError?: (error: unknown) => void; +}) { + const [data, setData] = useState(undefined); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(null); + const [loaded, setLoaded] = useState(false); + + const refresh = async () => { + if (svc === null || def.fetch === undefined) return; + try { + setError(null); + const result = await def.fetch(svc); + setData(result); + setLoaded(true); + } catch (error) { + setError(error); + onError?.(error); + } + }; + + return ( +
+
+
+ {def.label} + +
+ {def.fetch !== undefined ? ( + void refresh()} disabled={svc === null}> + {loaded ? 'Refresh' : 'Load'} + + ) : null} +
+
+ {error !== null ?
: null} + {def.fetch !== undefined ? ( + loaded ? ( + + ) : ( +
click Load to read this Service
+ ) + ) : null} + {def.actions !== undefined && def.actions.length > 0 ? ( +
+ {def.actions.map((action) => ( + { + if (svc === null) return; + let input: string | undefined; + if (action.input !== undefined) { + const raw = window.prompt(action.input); + if (raw === null) return; + input = raw; + } + setBusy(action.label); + setError(null); + try { + const result = await action.run(svc, input); + if (result !== undefined && def.fetch === undefined) setData(result); + if (def.fetch !== undefined) await refresh(); + } catch (error) { + setError(error); + onError?.(error); + } finally { + setBusy(null); + } + }} + > + {busy === action.label ? '…' : action.label} + + ))} +
+ ) : null} + {def.fetch === undefined && data !== undefined ? ( +
+ ) : null} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Dynamic service card — generic renderer for channels without a handwritten +// override. Every method gets a call button labeled with its declared +// signature; parameters become structured inputs (`MethodArgInputs`: one +// field per parameter, one per key for destructured objects, smart-parsed — +// see `methodArgs.ts`). Getters become read buttons. Results render inline. +// --------------------------------------------------------------------------- + +function DynamicServiceCard({ + channel, + svc, + onError, + collapsible = true, + inlineResults = true, +}: { + channel: ChannelDescriptor; + svc: AnyService | null; + onError?: (error: unknown) => void; + /** + * Allow collapsing the method list behind the header. Off in the + * scrollspy layout, where every card stays expanded and the header name + * is a copy target instead of a toggle. + */ + collapsible?: boolean; + /** + * Render call results inline under each method. Off in the scrollspy + * layout, where results land in the right-side history pane instead. + */ + inlineResults?: boolean; +}) { + const [open, setOpen] = useState(false); + const [inputs, setInputs] = useState>>({}); + const [results, setResults] = useState>({}); + const [errors, setErrors] = useState>({}); + const [busy, setBusy] = useState(null); + + const invoke = async (method: ChannelDescriptor['methods'][number]) => { + if (svc === null) return; + let argv: unknown[]; + try { + argv = buildArgs(parseParamFields(method.params), inputs[method.name] ?? {}); + } catch { + setErrors((prev) => ({ ...prev, [method.name]: new Error('arg is not valid JSON') })); + return; + } + setBusy(method.name); + setErrors((prev) => ({ ...prev, [method.name]: null })); + try { + const result = await call(svc, method.name, ...argv); + setResults((prev) => ({ ...prev, [method.name]: result ?? '(no result)' })); + } catch (error) { + setErrors((prev) => ({ ...prev, [method.name]: error })); + onError?.(error); + } finally { + setBusy(null); + } + }; + + return ( +
+
setOpen((v) => !v) : undefined} + > +
+ + + {channel.methods.length} methods · {channel.domain} + +
+ {collapsible ? ( + {open ? '▾' : '▸'} + ) : null} +
+ {!collapsible || open ? ( +
+ {channel.methods.length === 0 ? ( +
no callable members
+ ) : null} + {channel.methods.map((m) => { + const fields = m.kind === 'method' ? parseParamFields(m.params) : []; + return ( +
+
+ void invoke(m)} + > + {busy === m.name ? '…' : `${m.name}(${m.params})`} + + {m.kind === 'property' ? get : null} +
+ {fields.length > 0 ? ( + + setInputs((prev) => ({ + ...prev, + [m.name]: { ...prev[m.name], [key]: value }, + })) + } + /> + ) : null} + {errors[m.name] ? ( +
+ +
+ ) : null} + {inlineResults && results[m.name] !== undefined ? ( +
+ +
+ ) : null} +
+ ); + })} +
+ ) : null} +
+ ); +} + +// --------------------------------------------------------------------------- +// Structured argument inputs — one labeled field per declared parameter. +// Values are smart-parsed on invoke (JSON when it parses, plain string +// otherwise), so simple strings need no quotes. +// --------------------------------------------------------------------------- + +function MethodArgInputs({ + fields, + values, + onChange, +}: { + fields: readonly ParamField[]; + values: Readonly>; + onChange: (key: string, value: string) => void; +}) { + return ( +
+ {fields.map((f, i) => { + if (f.kind === 'object') { + return ( +
+
{f.name}
+
+ {f.keys.map((key) => ( +
+ + onChange(fieldKey(i, key), v)} + /> +
+ ))} +
+
+ ); + } + if (f.kind === 'value') { + return ( +
+ + onChange(fieldKey(i), v)} + /> +
+ ); + } + return ( +
+ + onChange(fieldKey(i), v)} + /> +
+ ); + })} +
+ ); +} + +function ArgLabel({ label }: { label: string }) { + return ( + + {label} + + ); +} + +function ArgInput({ + placeholder, + value, + onChange, +}: { + placeholder: string; + value: string; + onChange: (value: string) => void; +}) { + return ( + onChange(e.target.value)} + /> + ); +} diff --git a/apps/kimi-inspect/src/components/methodArgs.test.ts b/apps/kimi-inspect/src/components/methodArgs.test.ts new file mode 100644 index 0000000000..920bb16a14 --- /dev/null +++ b/apps/kimi-inspect/src/components/methodArgs.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest'; + +import { buildArgs, parseParamFields, smartParse } from './methodArgs'; + +describe('parseParamFields', () => { + it('parses a single named parameter', () => { + expect(parseParamFields('sessionId')).toEqual([{ kind: 'value', name: 'sessionId' }]); + }); + + it('parses several named parameters', () => { + expect(parseParamFields('id, decision')).toEqual([ + { kind: 'value', name: 'id' }, + { kind: 'value', name: 'decision' }, + ]); + }); + + it('keeps declared defaults', () => { + expect(parseParamFields("count = 1, mode = 'auto'")).toEqual([ + { kind: 'value', name: 'count', defaultValue: '1' }, + { kind: 'value', name: 'mode', defaultValue: "'auto'" }, + ]); + }); + + it('does not split commas inside defaults', () => { + expect(parseParamFields("ids = ['a', 'b'], flag")).toEqual([ + { kind: 'value', name: 'ids', defaultValue: "['a', 'b']" }, + { kind: 'value', name: 'flag' }, + ]); + }); + + it('expands a destructured object parameter into keys', () => { + expect(parseParamFields('{ workspaceId, limit }')).toEqual([ + { kind: 'object', name: '{ workspaceId, limit }', keys: ['workspaceId', 'limit'] }, + ]); + }); + + it('strips key defaults and renames in object patterns', () => { + expect(parseParamFields('{ limit = 10, workspaceId: ws }')).toEqual([ + { + kind: 'object', + name: '{ limit = 10, workspaceId: ws }', + keys: ['limit', 'workspaceId'], + }, + ]); + }); + + it('keeps the object-pattern default', () => { + expect(parseParamFields('{ a, b } = {}')).toEqual([ + { kind: 'object', name: '{ a, b } = {}', keys: ['a', 'b'], defaultValue: '{}' }, + ]); + }); + + it('drops rest elements inside object patterns', () => { + expect(parseParamFields('{ a, ...rest }')).toEqual([ + { kind: 'object', name: '{ a, ...rest }', keys: ['a'] }, + ]); + }); + + it('falls back to raw for rest and array patterns', () => { + expect(parseParamFields('...args')).toEqual([{ kind: 'raw', label: '...args' }]); + expect(parseParamFields('[a, b]')).toEqual([{ kind: 'raw', label: '[a, b]' }]); + }); + + it('returns no fields for empty params', () => { + expect(parseParamFields('')).toEqual([]); + }); +}); + +describe('smartParse', () => { + it('parses JSON values', () => { + expect(smartParse('5')).toBe(5); + expect(smartParse('true')).toBe(true); + expect(smartParse('{"a":1}')).toEqual({ a: 1 }); + expect(smartParse('"x"')).toBe('x'); + }); + + it('passes non-JSON through as a plain string', () => { + expect(smartParse('main')).toBe('main'); + expect(smartParse('k2-thinking')).toBe('k2-thinking'); + }); +}); + +describe('buildArgs', () => { + it('builds a single argument from a value field', () => { + const fields = parseParamFields('sessionId'); + expect(buildArgs(fields, { '0': 'abc' })).toEqual(['abc']); + }); + + it('assembles a destructured object from its key fields', () => { + const fields = parseParamFields('{ workspaceId, limit }'); + expect(buildArgs(fields, { '0.workspaceId': 'ws1', '0.limit': '5' })).toEqual([ + { workspaceId: 'ws1', limit: 5 }, + ]); + }); + + it('drops empty keys and omits a fully-empty object', () => { + const fields = parseParamFields('{ workspaceId, limit }'); + expect(buildArgs(fields, { '0.limit': '5' })).toEqual([{ limit: 5 }]); + expect(buildArgs(fields, {})).toEqual([]); + }); + + it('uses declared defaults for empty value fields', () => { + const fields = parseParamFields("count = 1, mode = 'auto'"); + expect(buildArgs(fields, {})).toEqual([1, 'auto']); + expect(buildArgs(fields, { '1': 'yolo' })).toEqual([1, 'yolo']); + }); + + it('truncates trailing empty params but keeps interior holes', () => { + const fields = parseParamFields('a, b, c'); + expect(buildArgs(fields, { '0': 'x' })).toEqual(['x']); + // Interior hole: `b` empty, `c` filled — the hole serializes as null. + expect(JSON.parse(JSON.stringify(buildArgs(fields, { '0': 'x', '2': 'z' })))).toEqual([ + 'x', + null, + 'z', + ]); + }); + + it('parses raw fallback fields as strict JSON', () => { + const fields = parseParamFields('...args'); + expect(buildArgs(fields, { '0': '[1, 2]' })).toEqual([[1, 2]]); + expect(() => buildArgs(fields, { '0': 'not json' })).toThrow(); + }); +}); diff --git a/apps/kimi-inspect/src/components/methodArgs.ts b/apps/kimi-inspect/src/components/methodArgs.ts new file mode 100644 index 0000000000..4834e7a4e7 --- /dev/null +++ b/apps/kimi-inspect/src/components/methodArgs.ts @@ -0,0 +1,198 @@ +/** + * Structured method-argument editing for the dynamic Service cards, driven + * only by the wire `params` string (the declared parameter list parsed from + * `Function#toString` server-side — names only, no per-method schemas). + * + * `parseParamFields` splits the params string into editable fields: + * - a named parameter (`title`, `count = 1`) becomes one input; an empty + * input falls back to the declared default; + * - a destructured object parameter (`{ workspaceId, limit }`) becomes one + * input per key, assembled back into an object (empty keys are dropped); + * - anything unparseable (rest params, array patterns) falls back to one + * raw JSON input — the old behavior. + * + * Field values are smart-parsed (`smartParse`): text that parses as JSON is + * passed through parsed (numbers, booleans, objects, quoted strings), + * anything else goes as a plain string — so `main` needs no quotes while + * `{ "a": 1 }` still works. + */ + +export type ParamField = + | { readonly kind: 'value'; readonly name: string; readonly defaultValue?: string } + | { + readonly kind: 'object'; + /** Original pattern text, used as the group label (e.g. `{ workspaceId, limit }`). */ + readonly name: string; + readonly keys: readonly string[]; + readonly defaultValue?: string; + } + | { readonly kind: 'raw'; readonly label: string }; + +/** Input state key for a field: the param index, or `index.key` for object keys. */ +export function fieldKey(index: number, key?: string): string { + return key === undefined ? String(index) : `${index}.${key}`; +} + +/** + * Split a parameter list at top-level commas, tracking `(){}[]` nesting and + * string literals (defaults can contain commas, e.g. `= ['a', 'b']`). + */ +function splitTopLevel(src: string): string[] { + const parts: string[] = []; + let depth = 0; + let quote: string | null = null; + let start = 0; + for (let i = 0; i < src.length; i++) { + const ch = src[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + else if (ch === '(' || ch === '{' || ch === '[') depth++; + else if (ch === ')' || ch === '}' || ch === ']') depth--; + else if (ch === ',' && depth === 0) { + parts.push(src.slice(start, i)); + start = i + 1; + } + } + parts.push(src.slice(start)); + return parts; +} + +/** Index of the first `=` at nesting depth 0 (skipping `=>`, `==`, `===`), or -1. */ +function indexOfTopLevelEquals(src: string): number { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < src.length; i++) { + const ch = src[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + else if (ch === '(' || ch === '{' || ch === '[') depth++; + else if (ch === ')' || ch === '}' || ch === ']') depth--; + else if (ch === '=' && depth === 0 && src[i + 1] !== '=' && src[i + 1] !== '>') return i; + } + return -1; +} + +/** Index of the `}` matching the `{` at position 0 of `src`, or -1. */ +function matchingBrace(src: string): number { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < src.length; i++) { + const ch = src[i]!; + if (quote !== null) { + if (ch === '\\') i++; + else if (ch === quote) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === '`') quote = ch; + else if (ch === '{') depth++; + else if (ch === '}') { + depth--; + if (depth === 0) return i; + } + } + return -1; +} + +/** Property name out of one object-pattern entry: `a`, `a: b`, `a = 1`, `a: b = 1` → `a`. */ +function objectPatternKey(entry: string): string { + const colon = entry.indexOf(':'); + const eq = indexOfTopLevelEquals(entry); + let end = entry.length; + if (colon !== -1) end = Math.min(end, colon); + if (eq !== -1) end = Math.min(end, eq); + return entry.slice(0, end).trim(); +} + +/** Parse the wire `params` string into editable fields (see file header). */ +export function parseParamFields(params: string): readonly ParamField[] { + return splitTopLevel(params) + .map((s) => s.trim()) + .filter((s) => s !== '') + .map((part): ParamField => { + if (part.startsWith('{')) { + const close = matchingBrace(part); + if (close === -1) return { kind: 'raw', label: part }; + const keys = splitTopLevel(part.slice(1, close)) + .map((k) => objectPatternKey(k.trim())) + .filter((k) => k !== '' && !k.startsWith('...')); + const rest = part.slice(close + 1).trim(); + const defaultValue = rest.startsWith('=') ? rest.slice(1).trim() : undefined; + if (keys.length === 0) return { kind: 'raw', label: part }; + return { kind: 'object', name: part, keys, defaultValue }; + } + if (part.startsWith('[') || part.startsWith('...')) { + return { kind: 'raw', label: part }; + } + const eq = indexOfTopLevelEquals(part); + if (eq !== -1) { + const name = part.slice(0, eq).trim(); + const defaultValue = part.slice(eq + 1).trim(); + if (/^[A-Za-z_$][\w$]*$/.test(name)) return { kind: 'value', name, defaultValue }; + return { kind: 'raw', label: part }; + } + if (/^[A-Za-z_$][\w$]*$/.test(part)) return { kind: 'value', name: part }; + return { kind: 'raw', label: part }; + }); +} + +/** Smart-parse one field value: JSON when it parses, the raw string otherwise. */ +export function smartParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + // Declared defaults are source text, where strings use JS single-quote + // literals ('auto') that JSON.parse rejects — unquote those. + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replaceAll(/\\(.)/g, '$1'); + } + return raw; + } +} + +/** + * Assemble the wire argument array from field inputs (keyed by `fieldKey`). + * Empty value fields fall back to their declared default; empty object + * fields drop the key, and a fully-empty object param is omitted. Trailing + * holes are truncated; interior holes serialize as `null` on the wire. + * Throws when a raw field contains invalid JSON. + */ +export function buildArgs( + fields: readonly ParamField[], + values: Readonly>, +): unknown[] { + const args: unknown[] = []; + fields.forEach((field, i) => { + if (field.kind === 'value') { + const raw = (values[fieldKey(i)] ?? '').trim(); + if (raw !== '') args[i] = smartParse(raw); + else if (field.defaultValue !== undefined) args[i] = smartParse(field.defaultValue); + else args[i] = undefined; + } else if (field.kind === 'object') { + const obj: Record = {}; + let filled = false; + for (const key of field.keys) { + const raw = (values[fieldKey(i, key)] ?? '').trim(); + if (raw !== '') { + obj[key] = smartParse(raw); + filled = true; + } + } + args[i] = filled ? obj : undefined; + } else { + const raw = (values[fieldKey(i)] ?? '').trim(); + if (raw !== '') args[i] = JSON.parse(raw); + else args[i] = undefined; + } + }); + let end = args.length; + while (end > 0 && args[end - 1] === undefined) end--; + return args.slice(0, end); +} diff --git a/apps/kimi-inspect/src/panels.ts b/apps/kimi-inspect/src/panels.ts index a282c24467..58c3aa865d 100644 --- a/apps/kimi-inspect/src/panels.ts +++ b/apps/kimi-inspect/src/panels.ts @@ -42,15 +42,15 @@ import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/tool import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; /** Loosely-typed view of a scoped service proxy (every member is a remote call). */ -export type AnyService = Record Promise>; +export type AnyService = Record Promise>; /** Invoke a method on a loose proxy; the proxy materializes every member. */ -export function call(svc: AnyService, method: string, arg?: unknown): Promise { +export function call(svc: AnyService, method: string, ...args: unknown[]): Promise { const fn = svc[method]; if (fn === undefined) { return Promise.reject(new Error(`no such method on proxy: ${method}`)); } - return fn(arg); + return fn(...args); } export interface PanelAction { From 133786ecf40a95ce8a48637cf6ff4d3c031053ad Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 21 Jul 2026 18:13:39 +0800 Subject: [PATCH 03/17] refactor(agent-core-v2): restructure workspace domains - rename workspaceRegistry to workspace and workspaceLocalConfig to projectLocalConfig - extract id-spelling resolution into the workspaceAliases domain (IWorkspaceAliases) - extract workspace-centric session queries into workspaceSessions (IWorkspaceSessions) - update kap-server routes, klient contracts, and related tests to the new services --- .../scripts/check-domain-layers.mjs | 8 +- .../hostFolderBrowserService.ts | 6 +- .../projectLocalConfig/projectLocalConfig.ts | 33 ++++ .../app/sessionExport/sessionExportService.ts | 4 +- .../src/app/sessionIndex/sessionIndex.ts | 2 +- .../app/sessionIndex/sessionIndexService.ts | 2 +- .../sessionLifecycleService.ts | 22 +-- .../errors.ts | 0 .../fileWorkspacePersistence.ts | 20 +- .../workspace.ts} | 19 +- .../src/app/workspace/workspaceAlias.ts | 145 +++++++++++++++ .../workspacePersistence.ts | 13 +- .../workspaceService.ts} | 176 +++--------------- .../app/workspaceAliases/workspaceAliases.ts | 27 +++ .../workspaceAliasesService.ts | 58 ++++++ .../src/app/workspaceLocalConfig/index.ts | 7 - .../workspaceLocalConfig.ts | 30 --- .../app/workspaceRegistry/workspaceQuery.ts | 26 --- .../workspaceQueryService.ts | 32 ---- .../workspaceSessions/workspaceSessions.ts | 29 +++ .../workspaceSessionsService.ts | 49 +++++ packages/agent-core-v2/src/errors.ts | 4 +- packages/agent-core-v2/src/index.ts | 18 +- ...ervice.ts => projectLocalConfigService.ts} | 60 +++--- .../workspaceCommandService.ts | 8 +- .../app/sessionExport/sessionExport.test.ts | 5 +- .../sessionLifecycle/sessionLifecycle.test.ts | 73 ++++---- .../workspaceService.test.ts} | 97 ++-------- .../workspaceAliasesService.test.ts | 175 +++++++++++++++++ .../workspaceQueryService.test.ts | 91 --------- .../workspaceSessionsService.test.ts | 138 ++++++++++++++ .../workspaceCommand/workspaceCommand.test.ts | 6 +- packages/kap-server/src/routes/sessions.ts | 19 +- packages/kap-server/src/routes/skills.ts | 8 +- packages/kap-server/src/routes/snapshot.ts | 4 +- packages/kap-server/src/routes/workspaces.ts | 33 ++-- .../src/services/snapshot/snapshotReader.ts | 4 +- .../klient/src/contract/global/workspaces.ts | 4 +- packages/klient/src/contract/index.ts | 2 +- packages/klient/src/core/facade/global.ts | 12 +- packages/klient/src/index.ts | 2 +- .../src/transports/memory/serviceRegistry.ts | 4 +- packages/klient/test/contract-parity.ts | 2 +- packages/klient/test/facade.test.ts | 2 +- 44 files changed, 869 insertions(+), 610 deletions(-) create mode 100644 packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts rename packages/agent-core-v2/src/app/{workspaceRegistry => workspace}/errors.ts (100%) rename packages/agent-core-v2/src/app/{workspaceRegistry => workspace}/fileWorkspacePersistence.ts (87%) rename packages/agent-core-v2/src/app/{workspaceRegistry/workspaceRegistry.ts => workspace/workspace.ts} (57%) create mode 100644 packages/agent-core-v2/src/app/workspace/workspaceAlias.ts rename packages/agent-core-v2/src/app/{workspaceRegistry => workspace}/workspacePersistence.ts (81%) rename packages/agent-core-v2/src/app/{workspaceRegistry/workspaceRegistryService.ts => workspace/workspaceService.ts} (66%) create mode 100644 packages/agent-core-v2/src/app/workspaceAliases/workspaceAliases.ts create mode 100644 packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts delete mode 100644 packages/agent-core-v2/src/app/workspaceLocalConfig/index.ts delete mode 100644 packages/agent-core-v2/src/app/workspaceLocalConfig/workspaceLocalConfig.ts delete mode 100644 packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts delete mode 100644 packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts create mode 100644 packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts create mode 100644 packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts rename packages/agent-core-v2/src/persistence/backends/node-fs/{workspaceLocalConfigService.ts => projectLocalConfigService.ts} (82%) rename packages/agent-core-v2/test/app/{workspaceRegistry/workspaceRegistryService.test.ts => workspace/workspaceService.test.ts} (85%) create mode 100644 packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts delete mode 100644 packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts create mode 100644 packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts diff --git a/packages/agent-core-v2/scripts/check-domain-layers.mjs b/packages/agent-core-v2/scripts/check-domain-layers.mjs index 828b301ee5..8a8d5ef024 100644 --- a/packages/agent-core-v2/scripts/check-domain-layers.mjs +++ b/packages/agent-core-v2/scripts/check-domain-layers.mjs @@ -119,10 +119,12 @@ const DOMAIN_LAYER = new Map([ ['blob', 2], ['file', 2], ['config', 2], - ['workspaceLocalConfig', 2], + ['projectLocalConfig', 2], ['sessionFs', 2], ['process', 2], - ['workspaceRegistry', 2], + ['workspace', 2], + ['workspaceAliases', 2], + ['workspaceSessions', 2], ['hostFolderBrowser', 2], ['auth', 2], ['provider', 2], @@ -221,7 +223,7 @@ const DOMAIN_LAYER = new Map([ // `workspaceCommand` orchestrates session-level workspace mutations // (`addAdditionalDir`): it reaches through `agentLifecycle` (L6) to the // `main` agent's `contextMemory` (L4) to mirror the action's stdout, and - // delegates project-local config persistence to `workspaceLocalConfig` (L2). + // delegates project-local config persistence to `projectLocalConfig` (L2). // Its highest real dependency is `agentLifecycle`, so it sits in L6 beside // the other coordination domains. ['workspaceCommand', 6], diff --git a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts index 3264c9a17a..df48d92747 100644 --- a/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts +++ b/packages/agent-core-v2/src/app/hostFolderBrowser/hostFolderBrowserService.ts @@ -2,7 +2,7 @@ * `hostFolderBrowser` domain (L2) — `IHostFolderBrowser` implementation. * * Browses the real local filesystem through `node:fs/promises` and derives - * `recent_roots` from the process-wide `IWorkspaceRegistry`. Bound at App + * `recent_roots` from the process-wide `IWorkspaceService`. Bound at App * scope. Mirrors the v1 `WorkspaceFsService` behaviour so the `/api/v1` * transport stays wire-compatible: realpath resolution, directory-only * entries, dot-last sorting, and `parent` resolution. @@ -16,7 +16,7 @@ import type { FsBrowseEntry, FsBrowseResponse, FsHomeResponse } from './hostFold import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; +import { IWorkspaceService } from '#/app/workspace/workspace'; import { HostFolderNotAbsoluteError, @@ -29,7 +29,7 @@ import { export class HostFolderBrowser implements IHostFolderBrowser { declare readonly _serviceBrand: undefined; - constructor(@IWorkspaceRegistry private readonly registry: IWorkspaceRegistry) {} + constructor(@IWorkspaceService private readonly registry: IWorkspaceService) {} async browse(absPath?: string): Promise { const target = absPath ?? homedir(); diff --git a/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts new file mode 100644 index 0000000000..81b5868bc0 --- /dev/null +++ b/packages/agent-core-v2/src/app/projectLocalConfig/projectLocalConfig.ts @@ -0,0 +1,33 @@ +/** + * `projectLocalConfig` domain (L2) — project-local config access. + * + * Defines the App-scoped `IProjectLocalConfigService` contract for + * project-local `.kimi-code/local.toml` access. The service works purely by + * path: it discovers the project root (the nearest `.git` ancestor) from a + * working directory and reads/writes the project-local TOML there — it never + * touches the workspace catalog or a `workspaceId`. Session domains consume + * the resolved directory list and never parse or write the TOML document + * themselves; the local filesystem backend supplies the implementation. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface ProjectAdditionalDirsLoadResult { + readonly projectRoot: string; + readonly configPath: string; + readonly additionalDirs: readonly string[]; +} + +export interface IProjectLocalConfigService { + readonly _serviceBrand: undefined; + + readAdditionalDirs(workDir: string): Promise; + resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise; + appendAdditionalDir( + workDir: string, + inputPath: string, + ): Promise; +} + +export const IProjectLocalConfigService: ServiceIdentifier = + createDecorator('projectLocalConfigService'); diff --git a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts index 134dd8ea0e..ac7d7fdea8 100644 --- a/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts +++ b/packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts @@ -16,7 +16,7 @@ import { IWireService } from '#/wire/wire'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { ISessionLifecycleService } from '#/app/sessionLifecycle/sessionLifecycle'; -import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; +import { IWorkspaceService } from '#/app/workspace/workspace'; import { ErrorCodes, Error2 } from '#/errors'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionMetadata } from '#/session/sessionMetadata/sessionMetadata'; @@ -48,7 +48,7 @@ export class SessionExportService implements ISessionExportService { @IBootstrapService private readonly bootstrap: IBootstrapService, @ISessionIndex private readonly index: ISessionIndex, @ISessionLifecycleService private readonly lifecycle: ISessionLifecycleService, - @IWorkspaceRegistry private readonly workspaces: IWorkspaceRegistry, + @IWorkspaceService private readonly workspaces: IWorkspaceService, @ILogService private readonly log: ILogService, ) {} diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts index e898b6c273..2c196b6b91 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndex.ts @@ -35,7 +35,7 @@ export interface SessionListQuery { /** * Restrict to sessions persisted under any of these workspace ids. A single * workspace is `[id]`; callers resolving a legacy split bucket (one - * directory, several id spellings — see `IWorkspaceRegistry.resolveAliasIds`) + * directory, several id spellings — see `IWorkspaceAliases.resolveAliasIds`) * pass the whole alias set and get one merged listing. Absent lists every * bucket. */ diff --git a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts index a6a9b490dd..c43b8dc137 100644 --- a/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts +++ b/packages/agent-core-v2/src/app/sessionIndex/sessionIndexService.ts @@ -9,7 +9,7 @@ * * One physical folder may be split across sibling buckets by legacy id * spellings (Windows casing/slash variants minted different `workspaceId`s for - * the same directory; see `IWorkspaceRegistry.resolveAliasIds`). A list or + * the same directory; see `IWorkspaceAliases.resolveAliasIds`). A list or * `countActive` query takes the workspace-id *set*, enumerates each bucket, * and merges before the single recency sort and `limit` step — the merged * listing is observably identical to a single-bucket list (same sort key, diff --git a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts index 311370523c..eb2f592848 100644 --- a/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts +++ b/packages/agent-core-v2/src/app/sessionLifecycle/sessionLifecycleService.ts @@ -14,7 +14,7 @@ * Materializes the session's initial metadata on * creation by resolving `sessionMetadata`. Bound at App scope. Persisted * sessions are discovered through the `sessionIndex` read model, and workspace - * roots are remembered through `workspaceRegistry`. On create / fork the + * roots are remembered through `workspace`. On create / fork the * session is also appended to the shared `session_index.jsonl` so v1 clients * (TUI, export) can discover sessions created by the v2 engine; the entry is * indexed under the registry-resolved workspace id — the same id seeding the @@ -63,8 +63,8 @@ import { ISessionIndex, PARENT_SESSION_ID_KEY, } from '#/app/sessionIndex/sessionIndex'; -import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; -import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; +import { IWorkspaceService } from '#/app/workspace/workspace'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ErrorCodes, Error2, isError2 } from '#/errors'; import { createHooks } from '#/hooks'; @@ -136,9 +136,9 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec @IAtomicDocumentStore private readonly docs: IAtomicDocumentStore, @IHostFileSystem private readonly hostFs: IHostFileSystem, @ICronTaskPersistence private readonly cronStore: ICronTaskPersistence, - @IWorkspaceRegistry private readonly workspaceRegistry: IWorkspaceRegistry, - @IWorkspaceLocalConfigService - private readonly workspaceLocalConfig: IWorkspaceLocalConfigService, + @IWorkspaceService private readonly workspaces: IWorkspaceService, + @IProjectLocalConfigService + private readonly projectLocalConfig: IProjectLocalConfigService, @IEventService private readonly event: IEventService, @ITelemetryService private readonly telemetry: ITelemetryService, ) { @@ -181,7 +181,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec } private async materializeSession(opts: MaterializeSessionOptions): Promise { - const workspace = await this.workspaceRegistry.createOrTouch(opts.workDir); + const workspace = await this.workspaces.createOrTouch(opts.workDir); const workspaceId = opts.workspaceId ?? workspace.id; const sessionScope = this.bootstrap.sessionScope(workspaceId, opts.sessionId); const sessionDir = this.bootstrap.sessionDir(workspaceId, opts.sessionId); @@ -196,8 +196,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec scope: (subKey?: string): string => subKey === undefined || subKey === '' ? sessionScope : `${sessionScope}/${subKey}`, }; - const localWorkspaceDirs = await this.workspaceLocalConfig.readAdditionalDirs(opts.workDir); - const callerAdditionalDirs = await this.workspaceLocalConfig.resolveAdditionalDirs( + const localWorkspaceDirs = await this.projectLocalConfig.readAdditionalDirs(opts.workDir); + const callerAdditionalDirs = await this.projectLocalConfig.resolveAdditionalDirs( opts.workDir, opts.additionalDirs ?? [], ); @@ -288,7 +288,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec const summary = await this.index.get(sessionId); if (summary === undefined) return undefined; const workspace = - summary.cwd === undefined ? await this.workspaceRegistry.get(summary.workspaceId) : undefined; + summary.cwd === undefined ? await this.workspaces.get(summary.workspaceId) : undefined; const workDir = summary.cwd ?? workspace?.root; if (workDir === undefined) return undefined; @@ -381,7 +381,7 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec let target: ISessionScopeHandle | undefined; let targetSessionDir: string | undefined; try { - const workspace = await this.workspaceRegistry.get(workspaceId); + const workspace = await this.workspaces.get(workspaceId); if (workspace === undefined) { throw new Error2(ErrorCodes.WORKSPACE_NOT_FOUND, `workspace ${workspaceId} does not exist`); } diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/errors.ts b/packages/agent-core-v2/src/app/workspace/errors.ts similarity index 100% rename from packages/agent-core-v2/src/app/workspaceRegistry/errors.ts rename to packages/agent-core-v2/src/app/workspace/errors.ts diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts similarity index 87% rename from packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts rename to packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts index 7f04827d06..c63e1ba6de 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/fileWorkspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspace/fileWorkspacePersistence.ts @@ -1,5 +1,5 @@ /** - * `workspaceRegistry` domain (L1) — `FileWorkspacePersistence` implementation. + * `workspace` domain (L2) — `FileWorkspacePersistence` implementation. * * File backend of `IWorkspacePersistence`. Persists the catalog as a single * v1-compatible `workspaces.json` document at the storage root @@ -14,7 +14,7 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import type { Workspace } from './workspaceRegistry'; +import type { Workspace } from './workspace'; import { IWorkspacePersistence, type PersistedWorkspaceEntry, @@ -22,9 +22,9 @@ import { type WorkspaceCatalog, } from './workspacePersistence'; -const WORKSPACE_REGISTRY_VERSION = 1; -const WORKSPACE_REGISTRY_SCOPE = ''; -const WORKSPACE_REGISTRY_KEY = 'workspaces.json'; +const WORKSPACE_CATALOG_VERSION = 1; +const WORKSPACE_CATALOG_SCOPE = ''; +const WORKSPACE_CATALOG_KEY = 'workspaces.json'; export class FileWorkspacePersistence implements IWorkspacePersistence { declare readonly _serviceBrand: undefined; @@ -33,8 +33,8 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { async load(): Promise { const file = await this.docs.get( - WORKSPACE_REGISTRY_SCOPE, - WORKSPACE_REGISTRY_KEY, + WORKSPACE_CATALOG_SCOPE, + WORKSPACE_CATALOG_KEY, ); if (file === undefined) return undefined; if ( @@ -76,11 +76,11 @@ export class FileWorkspacePersistence implements IWorkspacePersistence { }; } const file: PersistedWorkspaceFile = { - version: WORKSPACE_REGISTRY_VERSION, + version: WORKSPACE_CATALOG_VERSION, workspaces: record, deleted_workspace_ids: [...catalog.deletedIds], }; - await this.docs.set(WORKSPACE_REGISTRY_SCOPE, WORKSPACE_REGISTRY_KEY, file); + await this.docs.set(WORKSPACE_CATALOG_SCOPE, WORKSPACE_CATALOG_KEY, file); } } @@ -113,5 +113,5 @@ registerScopedService( IWorkspacePersistence, FileWorkspacePersistence, InstantiationType.Eager, - 'workspaceRegistry', + 'workspace', ); diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts b/packages/agent-core-v2/src/app/workspace/workspace.ts similarity index 57% rename from packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts rename to packages/agent-core-v2/src/app/workspace/workspace.ts index b2c08e045d..91345cf4a4 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistry.ts +++ b/packages/agent-core-v2/src/app/workspace/workspace.ts @@ -1,7 +1,7 @@ /** - * `workspaceRegistry` domain (L1) — process-wide catalog of known workspaces. + * `workspace` domain (L2) — process-wide catalog of known workspaces. * - * Defines the `IWorkspaceRegistry` used by the program side to remember the + * Defines the `IWorkspaceService` used by the program side to remember the * folders the user has opened (backed by the app's own persistence). This is * a host-side catalog, distinct from the session-scoped `workspaceContext` * that describes one Agent's active work directory. App-scoped. @@ -21,20 +21,11 @@ export interface WorkspaceUpdate { readonly name?: string; } -export interface IWorkspaceRegistry { +export interface IWorkspaceService { readonly _serviceBrand: undefined; list(): Promise; get(id: string): Promise; - /** - * Every persisted id that addresses the same physical directory as `id`: - * registered entries whose `workspaceRootKey` identity matches, plus - * session-index-only spellings (`session_index.jsonl` workDirs never seen by - * the registry, i.e. legacy split buckets). Read-only — ids/buckets are never - * rewritten. An unknown `id` resolves to `[id]` so callers keep their - * existing not-found semantics. - */ - resolveAliasIds(id: string): Promise; /** * Register (or refresh `lastOpenedAt` for) a workspace rooted at `root`. * Throws `fs.path_not_found` when `root` is missing or not a directory — @@ -45,5 +36,5 @@ export interface IWorkspaceRegistry { delete(id: string): Promise; } -export const IWorkspaceRegistry: ServiceIdentifier = - createDecorator('workspaceRegistry'); +export const IWorkspaceService: ServiceIdentifier = + createDecorator('workspaceService'); diff --git a/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts b/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts new file mode 100644 index 0000000000..d7e3eeabb7 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspace/workspaceAlias.ts @@ -0,0 +1,145 @@ +/** + * `workspace` domain (L2) — alias-folding pure helpers. + * + * One physical folder can arrive under several id spellings (Windows + * drive-letter casing, slash direction, typed-vs-realpath variants, legacy + * `encodeWorkDirKey` outputs). These helpers enumerate or collapse those + * spellings without owning any state: `collectAliasIds` expands one root to + * every id that addresses it, `dedupeByRoot` collapses a catalog to one + * representative per directory, and the session-index readers parse the + * legacy v1 `session_index.jsonl`. Shared by `WorkspaceService` (delete and + * list) and `WorkspaceAliasesService` (`resolveAliasIds`). + */ + +import { isAbsolute } from 'pathe'; + +import { encodeWorkDirKey, workspaceRootKey } from '#/_base/utils/workdir-slug'; +import type { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import type { Workspace } from './workspace'; + +// Legacy v1 session index, read for the one-shot rebuild and for alias +// folding (session-index-only id spellings). Empty scope resolves to +// `/` (join skips empty segments). +export const SESSION_INDEX_SCOPE = ''; +export const SESSION_INDEX_KEY = 'session_index.jsonl'; + +const textDecoder = new TextDecoder(); + +export interface SessionIndexLine { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +/** + * Every id identifying `root`'s directory: all registered spellings plus + * `workDir` spellings recorded only in `session_index.jsonl` (legacy split + * buckets that were never registered). Read-only — ids/buckets are never + * rewritten. + */ +export function collectAliasIds( + workspaces: readonly Workspace[], + sessionIndexEntries: readonly SessionIndexLine[], + root: string, +): string[] { + const rootKey = workspaceRootKey(root); + const ids: string[] = []; + const seen = new Set(); + const add = (alias: string): void => { + if (seen.has(alias)) return; + seen.add(alias); + ids.push(alias); + }; + for (const ws of workspaces) { + if (workspaceRootKey(ws.root) === rootKey) add(ws.id); + } + // Legacy split buckets may exist under spellings never registered: fold in + // every session-index `workDir` that identifies the same directory. + for (const line of sessionIndexEntries) { + if (workspaceRootKey(line.workDir) === rootKey) add(encodeWorkDirKey(line.workDir)); + } + return ids; +} + +/** + * Collapse registered workspaces that identify the same directory. The + * persisted catalog (v1-compatible `workspaces.json`) can hold legacy entries + * whose id was computed by an older `encodeWorkDirKey` (e.g. realpath-based on + * Windows) for the same folder, and Windows roots additionally differ by + * casing or slash spelling, so one directory may map to multiple ids. Entries + * merge on the `workspaceRootKey` identity key; prefer the entry whose id + * matches the canonical key computed on its own root string so current + * sessions' `workspace_id` still resolves and the same folder is not listed + * twice. + */ +export function dedupeByRoot(byId: ReadonlyMap): Workspace[] { + const byRoot = new Map(); + for (const ws of byId.values()) { + const rootKey = workspaceRootKey(ws.root); + const existing = byRoot.get(rootKey); + if (existing === undefined) { + byRoot.set(rootKey, ws); + continue; + } + const canonicalId = encodeWorkDirKey(ws.root); + if (existing.id !== canonicalId && ws.id === canonicalId) { + byRoot.set(rootKey, ws); + } + } + return [...byRoot.values()]; +} + +/** + * Parse the legacy v1 session index. Blank and malformed lines are skipped + * individually so one bad record never fails the whole file (matches the + * rebuild's tolerance). + */ +export async function readSessionIndexEntries( + storage: IFileSystemStorageService, +): Promise { + const bytes = await storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); + if (bytes === undefined) return []; + const entries: SessionIndexLine[] = []; + for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { + const trimmed = line.trim(); + if (trimmed === '') continue; + const entry = parseSessionIndexLine(trimmed); + if (entry === undefined) continue; + entries.push(entry); + } + return entries; +} + +export async function readSessionIndexWorkDirs( + storage: IFileSystemStorageService, +): Promise { + const workDirs: string[] = []; + for (const entry of await readSessionIndexEntries(storage)) { + if (!isAbsolute(entry.workDir)) continue; + workDirs.push(entry.workDir); + } + return workDirs; +} + +export function parseSessionIndexLine(line: string): SessionIndexLine | undefined { + try { + const parsed = JSON.parse(line) as unknown; + if (typeof parsed !== 'object' || parsed === null) return undefined; + const entry = parsed as Partial; + if ( + typeof entry.sessionId !== 'string' || + typeof entry.sessionDir !== 'string' || + typeof entry.workDir !== 'string' + ) { + return undefined; + } + return { + sessionId: entry.sessionId, + sessionDir: entry.sessionDir, + workDir: entry.workDir, + }; + } catch { + return undefined; + } +} diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts b/packages/agent-core-v2/src/app/workspace/workspacePersistence.ts similarity index 81% rename from packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts rename to packages/agent-core-v2/src/app/workspace/workspacePersistence.ts index 46ae19c201..37cae3bb1d 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspacePersistence.ts +++ b/packages/agent-core-v2/src/app/workspace/workspacePersistence.ts @@ -1,12 +1,12 @@ /** - * `workspaceRegistry` domain (L1) — `IWorkspacePersistence` contract. + * `workspace` domain (L2) — `IWorkspacePersistence` contract. * * Domain-specific persistence Store for the known-workspaces catalog. It hides * the on-disk document layout (`/workspaces.json`, the v1-compatible * `{ version, workspaces: { [id]: entry }, deleted_workspace_ids: string[] }` * shape — shared with agent-core, which reads and writes the same file) and * its serialization concerns (ISO ↔ epoch-ms, record ↔ array) from the - * registry. The generic `IAtomicDocumentStore` it builds on stays + * workspace service. The generic `IAtomicDocumentStore` it builds on stays * schema-agnostic. * * `deleted_workspace_ids` is the soft-delete tombstone list: ids the user @@ -14,14 +14,15 @@ * their ids must survive load/save round-trips so the session-index merge * never resurrects them. * - * `load()` returns `undefined` to mean "no usable catalog" so the registry can - * trigger a one-shot rebuild from the legacy session index; an empty catalog - * is a valid, already-materialized state and must NOT trigger a rebuild. + * `load()` returns `undefined` to mean "no usable catalog" so the workspace + * service can trigger a one-shot rebuild from the legacy session index; an + * empty catalog is a valid, already-materialized state and must NOT trigger a + * rebuild. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; -import type { Workspace } from './workspaceRegistry'; +import type { Workspace } from './workspace'; export interface PersistedWorkspaceEntry { readonly root: string; diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts b/packages/agent-core-v2/src/app/workspace/workspaceService.ts similarity index 66% rename from packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts rename to packages/agent-core-v2/src/app/workspace/workspaceService.ts index 27ba9458d7..ec122146c2 100644 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceRegistryService.ts +++ b/packages/agent-core-v2/src/app/workspace/workspaceService.ts @@ -1,5 +1,5 @@ /** - * `workspaceRegistry` domain (L1) — `IWorkspaceRegistry` implementation. + * `workspace` domain (L2) — `IWorkspaceService` implementation. * * Process-wide catalog of known workspaces, durable in * `/workspaces.json` (the v1-compatible file shared with @@ -50,11 +50,12 @@ * * Legacy data may still be split: two registry entries (or a registry entry * plus session-index-only spellings) for one physical folder, with sessions - * bucketed per id. `resolveAliasIds` is the read-only counterpart to the - * write-path folding: it enumerates every id spelling that identifies one - * directory (registered entries by `workspaceRootKey`, plus `workDir` - * spellings recorded only in `session_index.jsonl`) so readers can query all - * sibling buckets at once without rewriting any stored id. + * bucketed per id. The read-side counterpart to the write-path folding — + * enumerating every id spelling of one directory so readers can query all + * sibling buckets at once — lives in `IWorkspaceAliases` (`workspaceAliases` + * domain), built on the shared `workspaceAlias` helpers. `delete` folds the + * same alias set inside the op mutex so a sibling spelling cannot resurface + * as this directory's representative on the next `list()`. */ import { basename, isAbsolute } from 'pathe'; @@ -66,24 +67,16 @@ import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IWorkspaceRegistry, type Workspace, type WorkspaceUpdate } from './workspaceRegistry'; +import { IWorkspaceService, type Workspace, type WorkspaceUpdate } from './workspace'; +import { + collectAliasIds, + dedupeByRoot, + readSessionIndexEntries, + readSessionIndexWorkDirs, +} from './workspaceAlias'; import { IWorkspacePersistence, type WorkspaceCatalog } from './workspacePersistence'; -// Legacy v1 session index, read for the one-shot rebuild and for -// `resolveAliasIds` (session-index-only id spellings). Empty scope resolves to -// `/` (join skips empty segments). -const SESSION_INDEX_SCOPE = ''; -const SESSION_INDEX_KEY = 'session_index.jsonl'; - -const textDecoder = new TextDecoder(); - -interface SessionIndexLine { - readonly sessionId: string; - readonly sessionDir: string; - readonly workDir: string; -} - -export class WorkspaceRegistryService implements IWorkspaceRegistry { +export class WorkspaceService implements IWorkspaceService { declare readonly _serviceBrand: undefined; /** Whether the once-per-process session-index sync already ran. */ @@ -113,43 +106,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { }); } - resolveAliasIds(id: string): Promise { - return this.runExclusive(async () => { - await this.ensureMerged(); - const catalog = await this.loadCatalog(); - const entry = catalog.workspaces.find((ws) => ws.id === id); - // Unknown ids stay singletons so callers keep their not-found semantics. - if (entry === undefined) return [id]; - return this.collectAliasIds(catalog, entry.root); - }); - } - - /** Every id identifying `root`'s directory: all registered spellings plus - * `workDir` spellings recorded only in `session_index.jsonl` (legacy split - * buckets that were never registered). Callers must already hold the op - * mutex — this is the unfolded core of `resolveAliasIds`, shared with - * `delete`. */ - private async collectAliasIds(catalog: WorkspaceCatalog, root: string): Promise { - const rootKey = workspaceRootKey(root); - const ids: string[] = []; - const seen = new Set(); - const add = (alias: string): void => { - if (seen.has(alias)) return; - seen.add(alias); - ids.push(alias); - }; - for (const ws of catalog.workspaces) { - if (workspaceRootKey(ws.root) === rootKey) add(ws.id); - } - // Legacy split buckets may exist under spellings never registered: fold - // in every session-index `workDir` that identifies the same directory. - // Best-effort — a missing/corrupt index simply contributes nothing. - for (const line of await this.readSessionIndexEntries()) { - if (workspaceRootKey(line.workDir) === rootKey) add(encodeWorkDirKey(line.workDir)); - } - return ids; - } - createOrTouch(root: string, name?: string): Promise { return this.runExclusive(async () => { let stat; @@ -242,7 +198,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { if (root === undefined) { // Derived/unknown id: recover its spelling from the session index so // the whole alias set can still be tombstoned. - root = (await this.readSessionIndexEntries()).find( + root = (await readSessionIndexEntries(this.storage)).find( (line) => encodeWorkDirKey(line.workDir) === id, )?.workDir; } @@ -254,7 +210,11 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return; } const rootKey = workspaceRootKey(root); - const aliasIds = await this.collectAliasIds(catalog, root); + const aliasIds = collectAliasIds( + catalog.workspaces, + await readSessionIndexEntries(this.storage), + root, + ); await this.store.save({ workspaces: catalog.workspaces.filter((ws) => workspaceRootKey(ws.root) !== rootKey), deletedIds: [...new Set([...catalog.deletedIds, ...aliasIds])], @@ -297,7 +257,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { ): Promise { let changed = false; const now = Date.now(); - for (const workDir of await this.readSessionIndexWorkDirs()) { + for (const workDir of await readSessionIndexWorkDirs(this.storage)) { const id = encodeWorkDirKey(workDir); if (byId.has(id) || deletedIds.has(id)) continue; byId.set(id, { @@ -319,7 +279,7 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { // directory (Windows) collapse here too. First seen wins — the id stays // `encodeWorkDirKey` of that first-seen workDir string. const seenRootKeys = new Set(); - for (const entry of await this.readSessionIndexEntries()) { + for (const entry of await readSessionIndexEntries(this.storage)) { if (!isAbsolute(entry.workDir)) continue; const rootKey = workspaceRootKey(entry.workDir); if (seenRootKeys.has(rootKey)) continue; @@ -336,40 +296,6 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { return result; } - private async readSessionIndexWorkDirs(): Promise { - const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); - if (bytes === undefined) return []; - const workDirs: string[] = []; - for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === '') continue; - const entry = parseSessionIndexLine(trimmed); - if (entry === undefined) continue; - if (!isAbsolute(entry.workDir)) continue; - workDirs.push(entry.workDir); - } - return workDirs; - } - - /** - * Parse the legacy v1 session index. Blank and malformed lines are skipped - * individually so one bad record never fails the whole file (matches the - * rebuild's tolerance). - */ - private async readSessionIndexEntries(): Promise { - const bytes = await this.storage.read(SESSION_INDEX_SCOPE, SESSION_INDEX_KEY); - if (bytes === undefined) return []; - const entries: SessionIndexLine[] = []; - for (const line of textDecoder.decode(bytes).split(/\r?\n/)) { - const trimmed = line.trim(); - if (trimmed === '') continue; - const entry = parseSessionIndexLine(trimmed); - if (entry === undefined) continue; - entries.push(entry); - } - return entries; - } - private runExclusive(op: () => Promise): Promise { const next = this.opQueue.then(op, op); this.opQueue = next.then( @@ -380,60 +306,10 @@ export class WorkspaceRegistryService implements IWorkspaceRegistry { } } -function parseSessionIndexLine(line: string): SessionIndexLine | undefined { - try { - const parsed = JSON.parse(line) as unknown; - if (typeof parsed !== 'object' || parsed === null) return undefined; - const entry = parsed as Partial; - if ( - typeof entry.sessionId !== 'string' || - typeof entry.sessionDir !== 'string' || - typeof entry.workDir !== 'string' - ) { - return undefined; - } - return { - sessionId: entry.sessionId, - sessionDir: entry.sessionDir, - workDir: entry.workDir, - }; - } catch { - return undefined; - } -} - -/** - * Collapse registered workspaces that identify the same directory. The - * persisted catalog (v1-compatible `workspaces.json`) can hold legacy entries - * whose id was computed by an older `encodeWorkDirKey` (e.g. realpath-based on - * Windows) for the same folder, and Windows roots additionally differ by - * casing or slash spelling, so one directory may map to multiple ids. Entries - * merge on the `workspaceRootKey` identity key; prefer the entry whose id - * matches the canonical key computed on its own root string so current - * sessions' `workspace_id` still resolves and the same folder is not listed - * twice. - */ -function dedupeByRoot(byId: ReadonlyMap): Workspace[] { - const byRoot = new Map(); - for (const ws of byId.values()) { - const rootKey = workspaceRootKey(ws.root); - const existing = byRoot.get(rootKey); - if (existing === undefined) { - byRoot.set(rootKey, ws); - continue; - } - const canonicalId = encodeWorkDirKey(ws.root); - if (existing.id !== canonicalId && ws.id === canonicalId) { - byRoot.set(rootKey, ws); - } - } - return [...byRoot.values()]; -} - registerScopedService( LifecycleScope.App, - IWorkspaceRegistry, - WorkspaceRegistryService, + IWorkspaceService, + WorkspaceService, InstantiationType.Eager, - 'workspaceRegistry', + 'workspace', ); diff --git a/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliases.ts b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliases.ts new file mode 100644 index 0000000000..6931d627fd --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliases.ts @@ -0,0 +1,27 @@ +/** + * `workspaceAliases` domain (L2) — workspace id-spelling resolution contract. + * + * Defines the App-scoped `IWorkspaceAliases`: the read-side counterpart to the + * workspace write-path folding. One physical folder may be addressable by + * several id spellings (legacy split buckets); this service enumerates them so + * readers can query every sibling session bucket at once. App-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +export interface IWorkspaceAliases { + readonly _serviceBrand: undefined; + + /** + * Every persisted id that addresses the same physical directory as `id`: + * registered entries whose `workspaceRootKey` identity matches, plus + * session-index-only spellings (`session_index.jsonl` workDirs never seen by + * the workspace catalog, i.e. legacy split buckets). Read-only — ids/buckets + * are never rewritten. An unknown `id` resolves to `[id]` so callers keep + * their existing not-found semantics. + */ + resolveAliasIds(id: string): Promise; +} + +export const IWorkspaceAliases: ServiceIdentifier = + createDecorator('workspaceAliases'); diff --git a/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts new file mode 100644 index 0000000000..89f03b2adc --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceAliases/workspaceAliasesService.ts @@ -0,0 +1,58 @@ +/** + * `workspaceAliases` domain (L2) — `IWorkspaceAliases` implementation. + * + * Resolves every id spelling of one physical directory by folding the + * registered catalog (by `workspaceRootKey`) together with `workDir` + * spellings recorded only in the legacy `session_index.jsonl`, through the + * shared `workspaceAlias` helpers. The catalog is reached through + * `IWorkspaceService.get` first — its once-per-process session-index sync + * (`ensureMerged`) must have run before the raw catalog is read from + * `IWorkspacePersistence` — and the raw (un-deduped) catalog is required + * because `IWorkspaceService.list` collapses sibling spellings to one + * representative, which would defeat the alias enumeration. Read-only: no id + * or bucket is ever rewritten here. Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { IWorkspaceService } from '#/app/workspace/workspace'; +import { + collectAliasIds, + readSessionIndexEntries, +} from '#/app/workspace/workspaceAlias'; +import { IWorkspacePersistence } from '#/app/workspace/workspacePersistence'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; + +import { IWorkspaceAliases } from './workspaceAliases'; + +export class WorkspaceAliasesService implements IWorkspaceAliases { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceService private readonly workspaces: IWorkspaceService, + @IWorkspacePersistence private readonly store: IWorkspacePersistence, + @IFileSystemStorageService private readonly storage: IFileSystemStorageService, + ) {} + + async resolveAliasIds(id: string): Promise { + // Goes through the workspace service so the once-per-process session-index + // sync has run before the raw catalog is read below. + const entry = await this.workspaces.get(id); + // Unknown ids stay singletons so callers keep their not-found semantics. + if (entry === undefined) return [id]; + const catalog = (await this.store.load()) ?? { workspaces: [], deletedIds: [] }; + return collectAliasIds( + catalog.workspaces, + await readSessionIndexEntries(this.storage), + entry.root, + ); + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceAliases, + WorkspaceAliasesService, + InstantiationType.Eager, + 'workspaceAliases', +); diff --git a/packages/agent-core-v2/src/app/workspaceLocalConfig/index.ts b/packages/agent-core-v2/src/app/workspaceLocalConfig/index.ts deleted file mode 100644 index 417257f435..0000000000 --- a/packages/agent-core-v2/src/app/workspaceLocalConfig/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * `workspaceLocalConfig` domain barrel — re-exports the project-local config - * contract (`workspaceLocalConfig`). The node-fs backend registers the - * `IWorkspaceLocalConfigService` binding. - */ - -export * from './workspaceLocalConfig'; diff --git a/packages/agent-core-v2/src/app/workspaceLocalConfig/workspaceLocalConfig.ts b/packages/agent-core-v2/src/app/workspaceLocalConfig/workspaceLocalConfig.ts deleted file mode 100644 index c8b7011bf4..0000000000 --- a/packages/agent-core-v2/src/app/workspaceLocalConfig/workspaceLocalConfig.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * `workspaceLocalConfig` domain (L2) — project-local workspace config access. - * - * Defines the App-scoped `IWorkspaceLocalConfigService` contract for - * project-local `.kimi-code/local.toml` access. Session domains consume the - * resolved directory list and never parse or write the TOML document - * themselves; the local filesystem backend supplies the implementation. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -export interface WorkspaceAdditionalDirsLoadResult { - readonly projectRoot: string; - readonly configPath: string; - readonly additionalDirs: readonly string[]; -} - -export interface IWorkspaceLocalConfigService { - readonly _serviceBrand: undefined; - - readAdditionalDirs(workDir: string): Promise; - resolveAdditionalDirs(baseDir: string, additionalDirs: readonly string[]): Promise; - appendAdditionalDir( - workDir: string, - inputPath: string, - ): Promise; -} - -export const IWorkspaceLocalConfigService: ServiceIdentifier = - createDecorator('workspaceLocalConfigService'); diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts deleted file mode 100644 index 553d81c121..0000000000 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQuery.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * `workspaceRegistry` domain (L2) — workspace read-model query contract. - * - * Defines `IWorkspaceQueryService`, an App-scope read facade that answers - * workspace-centric queries spanning the workspace catalog and the session - * index. Today it exposes the most recent sessions in a workspace, projected - * as the session index's `SessionSummary`. Read-only and JSON-in/JSON-out so - * it is directly exposable on the `/api/v2` transport. App-scoped. - */ - -import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; - -import type { SessionSummary } from '#/app/sessionIndex/sessionIndex'; - -export type { SessionSummary }; - -export const RECENT_SESSIONS_LIMIT = 20; - -export interface IWorkspaceQueryService { - readonly _serviceBrand: undefined; - - listRecentSessions(workspaceId: string): Promise; -} - -export const IWorkspaceQueryService: ServiceIdentifier = - createDecorator('workspaceQuery'); diff --git a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts b/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts deleted file mode 100644 index 64255ca63e..0000000000 --- a/packages/agent-core-v2/src/app/workspaceRegistry/workspaceQueryService.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * `workspaceRegistry` domain (L2) — `IWorkspaceQueryService` implementation. - * - * Answers workspace-centric read queries by composing the persisted session - * index (`sessionIndex`); the recent-sessions list is delegated to - * `sessionIndex` with the capped `RECENT_SESSIONS_LIMIT`. Bound at App scope. - */ - -import { InstantiationType } from '#/_base/di/extensions'; -import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; - -import { IWorkspaceQueryService, RECENT_SESSIONS_LIMIT } from './workspaceQuery'; - -export class WorkspaceQueryService implements IWorkspaceQueryService { - declare readonly _serviceBrand: undefined; - - constructor(@ISessionIndex private readonly index: ISessionIndex) {} - - async listRecentSessions(workspaceId: string): Promise { - const page = await this.index.list({ workspaceIds: [workspaceId], limit: RECENT_SESSIONS_LIMIT }); - return page.items; - } -} - -registerScopedService( - LifecycleScope.App, - IWorkspaceQueryService, - WorkspaceQueryService, - InstantiationType.Eager, - 'workspaceRegistry', -); diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts new file mode 100644 index 0000000000..92c0528648 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessions.ts @@ -0,0 +1,29 @@ +/** + * `workspaceSessions` domain (L2) — workspace ↔ session query contract. + * + * Defines `IWorkspaceSessions`, an App-scope read facade answering + * workspace-centric queries over the session index: the most recent sessions + * of a workspace and its total session count. Every query first folds the + * workspace id through `IWorkspaceAliases` so legacy split buckets (one + * directory, several id spellings) answer as one workspace. Read-only and + * JSON-in/JSON-out so it is directly exposable on the `/api/v2` transport. + * App-scoped. + */ + +import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; + +import type { SessionSummary } from '#/app/sessionIndex/sessionIndex'; + +export type { SessionSummary }; + +export const RECENT_SESSIONS_LIMIT = 20; + +export interface IWorkspaceSessions { + readonly _serviceBrand: undefined; + + listRecent(workspaceId: string): Promise; + count(workspaceId: string): Promise; +} + +export const IWorkspaceSessions: ServiceIdentifier = + createDecorator('workspaceSessions'); diff --git a/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts new file mode 100644 index 0000000000..575d666326 --- /dev/null +++ b/packages/agent-core-v2/src/app/workspaceSessions/workspaceSessionsService.ts @@ -0,0 +1,49 @@ +/** + * `workspaceSessions` domain (L2) — `IWorkspaceSessions` implementation. + * + * Answers workspace-centric read queries by composing the alias resolver + * (`workspaceAliases`) with the persisted session index (`sessionIndex`): + * every query expands the workspace id to its full alias set first, so legacy + * split buckets count once for the workspace, not per bucket. The + * recent-sessions list is capped at `RECENT_SESSIONS_LIMIT`; the count covers + * archived sessions too. Bound at App scope. + */ + +import { InstantiationType } from '#/_base/di/extensions'; +import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; +import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases'; + +import { IWorkspaceSessions, RECENT_SESSIONS_LIMIT } from './workspaceSessions'; + +export class WorkspaceSessionsService implements IWorkspaceSessions { + declare readonly _serviceBrand: undefined; + + constructor( + @IWorkspaceAliases private readonly aliases: IWorkspaceAliases, + @ISessionIndex private readonly index: ISessionIndex, + ) {} + + async listRecent(workspaceId: string): Promise { + const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); + const page = await this.index.list({ workspaceIds, limit: RECENT_SESSIONS_LIMIT }); + return page.items; + } + + async count(workspaceId: string): Promise { + // One set-query over the alias set (legacy split buckets): a single merged + // listing cannot double-count, and a singleton set behaves exactly as + // before. + const workspaceIds = await this.aliases.resolveAliasIds(workspaceId); + const page = await this.index.list({ workspaceIds, includeArchived: true }); + return page.items.length; + } +} + +registerScopedService( + LifecycleScope.App, + IWorkspaceSessions, + WorkspaceSessionsService, + InstantiationType.Eager, + 'workspaceSessions', +); diff --git a/packages/agent-core-v2/src/errors.ts b/packages/agent-core-v2/src/errors.ts index b5b34d6d3c..d126af4764 100644 --- a/packages/agent-core-v2/src/errors.ts +++ b/packages/agent-core-v2/src/errors.ts @@ -33,7 +33,7 @@ import { StorageErrors } from '#/persistence/interface/storage'; import { TerminalErrors } from '#/os/interface/terminalErrors'; import { UsageErrors } from '#/agent/usage/errors'; import { WireErrors } from '#/wire/errors'; -import { WorkspaceErrors } from '#/app/workspaceRegistry/errors'; +import { WorkspaceErrors } from '#/app/workspace/errors'; export * from '#/_base/errors/codes'; export * from '#/_base/errors/errorMessage'; @@ -65,7 +65,7 @@ export { StorageErrors } from '#/persistence/interface/storage'; export { TerminalErrors } from '#/os/interface/terminalErrors'; export { UsageErrors } from '#/agent/usage/errors'; export { WireErrors } from '#/wire/errors'; -export { WorkspaceErrors } from '#/app/workspaceRegistry/errors'; +export { WorkspaceErrors } from '#/app/workspace/errors'; export const ErrorCodes = { ...CoreErrors.codes, diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index c7177697ed..e47c29a51c 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -299,12 +299,16 @@ export * from '#/session/workspaceContext/workspaceContext'; export * from '#/session/workspaceContext/workspaceContextService'; export * from '#/session/workspaceCommand/workspaceCommand'; export * from '#/session/workspaceCommand/workspaceCommandService'; -export * from '#/app/workspaceLocalConfig/workspaceLocalConfig'; -export * from '#/app/workspaceRegistry/workspaceRegistry'; -export * from '#/app/workspaceRegistry/workspaceRegistryService'; -export * from '#/app/workspaceRegistry/workspacePersistence'; -export * from '#/app/workspaceRegistry/fileWorkspacePersistence'; -import '#/app/workspaceRegistry/workspaceQueryService'; +export * from '#/app/projectLocalConfig/projectLocalConfig'; +export * from '#/app/workspace/workspace'; +export * from '#/app/workspace/workspaceService'; +export * from '#/app/workspace/workspaceAlias'; +export * from '#/app/workspace/workspacePersistence'; +export * from '#/app/workspace/fileWorkspacePersistence'; +export * from '#/app/workspaceAliases/workspaceAliases'; +import '#/app/workspaceAliases/workspaceAliasesService'; +export * from '#/app/workspaceSessions/workspaceSessions'; +import '#/app/workspaceSessions/workspaceSessionsService'; import '#/app/git/gitService'; export * from '#/session/process/processRunner'; export * from '#/session/process/processRunnerService'; @@ -327,7 +331,7 @@ export * from '#/persistence/backends/node-fs/fileStorageService'; export * from '#/persistence/backends/node-fs/appendLogStore'; export * from '#/persistence/backends/node-fs/atomicDocumentStore'; export * from '#/persistence/backends/node-fs/blobStoreService'; -export * from '#/persistence/backends/node-fs/workspaceLocalConfigService'; +export * from '#/persistence/backends/node-fs/projectLocalConfigService'; import '#/persistence/backends/minidb/flag'; export * from '#/persistence/backends/minidb/miniDbQueryStore'; export * from '#/persistence/backends/memory/inMemoryStorageService'; diff --git a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts similarity index 82% rename from packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts rename to packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts index 4af6d91ab2..db5506b881 100644 --- a/packages/agent-core-v2/src/persistence/backends/node-fs/workspaceLocalConfigService.ts +++ b/packages/agent-core-v2/src/persistence/backends/node-fs/projectLocalConfigService.ts @@ -1,10 +1,12 @@ /** - * `FileWorkspaceLocalConfigService` — node-fs backend for `IWorkspaceLocalConfigService`. + * `FileProjectLocalConfigService` — node-fs backend for `IProjectLocalConfigService`. * * Discovers project roots, parses and writes project-local * `.kimi-code/local.toml`, resolves additional directories with * v1-compatible OS-home expansion through `bootstrap`, and accesses the local - * filesystem through `hostFs`. Bound at App scope. + * filesystem through `hostFs`. Works purely by path (project-root discovery + * via the nearest `.git` ancestor); it never touches the workspace catalog or + * a `workspaceId`. Bound at App scope. */ import { dirname, isAbsolute, join, normalize, resolve } from 'pathe'; @@ -15,14 +17,14 @@ import { InstantiationType } from '#/_base/di/extensions'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { - IWorkspaceLocalConfigService, - type WorkspaceAdditionalDirsLoadResult, -} from '#/app/workspaceLocalConfig/workspaceLocalConfig'; + IProjectLocalConfigService, + type ProjectAdditionalDirsLoadResult, +} from '#/app/projectLocalConfig/projectLocalConfig'; import { ErrorCodes, Error2, unwrapErrorCause } from '#/errors'; import { IHostFileSystem } from '#/os/interface/hostFileSystem'; import { StorageError, StorageErrors, toStorageIoError } from '#/persistence/interface/storage'; -const WorkspaceLocalTomlSchema = z.object({ +const ProjectLocalTomlSchema = z.object({ workspace: z .object({ additional_dir: z.array(z.string()), @@ -30,14 +32,14 @@ const WorkspaceLocalTomlSchema = z.object({ .optional(), }); -type WorkspaceLocalToml = z.infer; +type ProjectLocalToml = z.infer; -interface WorkspaceLocalTomlFile { +interface ProjectLocalTomlFile { readonly raw: Record; - readonly parsed: WorkspaceLocalToml; + readonly parsed: ProjectLocalToml; } -export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigService { +export class FileProjectLocalConfigService implements IProjectLocalConfigService { declare readonly _serviceBrand: undefined; constructor( @@ -45,10 +47,10 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer @IHostFileSystem private readonly fs: IHostFileSystem, ) {} - async readAdditionalDirs(workDir: string): Promise { + async readAdditionalDirs(workDir: string): Promise { const projectRoot = await this.findProjectRoot(workDir); - const configPath = this.getWorkspaceLocalConfigPath(projectRoot); - const file = await this.readWorkspaceLocalToml(configPath); + const configPath = this.getProjectLocalConfigPath(projectRoot); + const file = await this.readProjectLocalToml(configPath); const additionalDirs = file?.parsed.workspace?.additional_dir; if (additionalDirs === undefined) { @@ -69,11 +71,11 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer async appendAdditionalDir( workDir: string, inputPath: string, - ): Promise { + ): Promise { const projectRoot = await this.findProjectRoot(workDir); - const configPath = this.getWorkspaceLocalConfigPath(projectRoot); + const configPath = this.getProjectLocalConfigPath(projectRoot); const additionalDir = await this.resolveAdditionalDir(workDir, inputPath); - const file = (await this.readWorkspaceLocalToml(configPath)) ?? { raw: {}, parsed: {} }; + const file = (await this.readProjectLocalToml(configPath)) ?? { raw: {}, parsed: {} }; const fileAdditionalDirs = file.parsed.workspace?.additional_dir ?? []; const fileExistingDirs = this.resolveExistingAdditionalDirs(projectRoot, fileAdditionalDirs); @@ -95,7 +97,7 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer return { projectRoot, configPath, additionalDirs: [...fileExistingDirs, additionalDir] }; } - private getWorkspaceLocalConfigPath(projectRoot: string): string { + private getProjectLocalConfigPath(projectRoot: string): string { return join(projectRoot, '.kimi-code', 'local.toml'); } @@ -111,9 +113,9 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer } } - private async readWorkspaceLocalToml( + private async readProjectLocalToml( configPath: string, - ): Promise { + ): Promise { let text: string; try { text = await this.fs.readText(configPath); @@ -141,11 +143,11 @@ export class FileWorkspaceLocalConfigService implements IWorkspaceLocalConfigSer if (!isPlainObject(raw)) { throw new Error2( ErrorCodes.CONFIG_INVALID, - `Invalid workspace local config in ${configPath}`, + `Invalid project local config in ${configPath}`, ); } - return { raw: cloneRecord(raw), parsed: parseWorkspaceLocalToml(raw) }; + return { raw: cloneRecord(raw), parsed: parseProjectLocalToml(raw) }; } private async resolveAdditionalDirsInternal( @@ -267,12 +269,12 @@ function normalizeAdditionalDirInput(additionalDir: string): string { return normalize(trimmed); } -function parseWorkspaceLocalToml(raw: Record): WorkspaceLocalToml { +function parseProjectLocalToml(raw: Record): ProjectLocalToml { try { - return WorkspaceLocalTomlSchema.parse(raw); + return ProjectLocalTomlSchema.parse(raw); } catch (error: unknown) { if (error instanceof z.ZodError) { - throw new Error2(ErrorCodes.CONFIG_INVALID, describeWorkspaceLocalValidationError(error), { + throw new Error2(ErrorCodes.CONFIG_INVALID, describeProjectLocalValidationError(error), { cause: error, }); } @@ -280,13 +282,13 @@ function parseWorkspaceLocalToml(raw: Record): WorkspaceLocalTo } } -function describeWorkspaceLocalValidationError(error: z.ZodError): string { +function describeProjectLocalValidationError(error: z.ZodError): string { const issue = error.issues[0]; if (issue?.path[0] === 'workspace' && issue.path[1] === 'additional_dir') { return 'workspace.additional_dir must be an array of strings'; } if (issue?.path[0] === 'workspace') return 'workspace must be a table'; - return `Invalid workspace local config: ${error.message}`; + return `Invalid project local config: ${error.message}`; } function cloneRecord(value: unknown): Record { @@ -310,8 +312,8 @@ function getErrorCode(error: unknown): unknown { registerScopedService( LifecycleScope.App, - IWorkspaceLocalConfigService, - FileWorkspaceLocalConfigService, + IProjectLocalConfigService, + FileProjectLocalConfigService, InstantiationType.Eager, - 'workspaceLocalConfig', + 'projectLocalConfig', ); diff --git a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts index 225272fa70..62f16836bd 100644 --- a/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts +++ b/packages/agent-core-v2/src/session/workspaceCommand/workspaceCommandService.ts @@ -2,7 +2,7 @@ * `workspaceCommand` domain (L6) — `ISessionWorkspaceCommandService` implementation. * * Coordinates session-level workspace mutations: resolves and persists - * workspace-local config through `workspaceLocalConfig`, updates + * project-local config through `projectLocalConfig`, updates * `workspaceContext`, and mirrors command output into the main agent through * `agentLifecycle` and `contextMemory`. Bound at Session scope. */ @@ -12,7 +12,7 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; -import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { IAgentLifecycleService, MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; @@ -31,8 +31,8 @@ export class SessionWorkspaceCommandService private mutationQueue: Promise = Promise.resolve(); constructor( - @IWorkspaceLocalConfigService - private readonly localConfig: IWorkspaceLocalConfigService, + @IProjectLocalConfigService + private readonly localConfig: IProjectLocalConfigService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @IAgentLifecycleService private readonly agents: IAgentLifecycleService, ) { diff --git a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts index 2d78765bb6..9c1230c6b8 100644 --- a/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts +++ b/packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts @@ -47,7 +47,7 @@ import { ISessionLifecycleService, type SessionLifecycleHooks, } from '#/app/sessionLifecycle/sessionLifecycle'; -import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; +import { IWorkspaceService } from '#/app/workspace/workspace'; import { Error2 } from '#/errors'; import { createHooks } from '#/hooks'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; @@ -872,7 +872,7 @@ function registerSessionExportServices( throw new Error('createChild should not be called by session export'); }, }); - reg.defineInstance(IWorkspaceRegistry, { + reg.defineInstance(IWorkspaceService, { _serviceBrand: undefined, list: async () => [], get: async (id) => ({ @@ -882,7 +882,6 @@ function registerSessionExportServices( createdAt: 1, lastOpenedAt: 2, }), - resolveAliasIds: async (id) => [id], createOrTouch: async (root) => ({ id: 'ws_created', root, diff --git a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts index ced4de63cc..c5b769d679 100644 --- a/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts +++ b/packages/agent-core-v2/test/app/sessionLifecycle/sessionLifecycle.test.ts @@ -40,10 +40,10 @@ import { ISessionAgentProfileCatalog } from '#/session/sessionAgentProfileCatalo import { ISessionIndex, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; import { IAppendLogStore } from '#/persistence/interface/appendLogStore'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; -import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; import { SessionWorkspaceContextService } from '#/session/workspaceContext/workspaceContextService'; -import { IWorkspaceRegistry, type Workspace } from '#/app/workspaceRegistry/workspaceRegistry'; +import { IWorkspaceService, type Workspace } from '#/app/workspace/workspace'; import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; import { ISessionContext } from '#/session/sessionContext/sessionContext'; import { ITelemetryService } from '#/app/telemetry/telemetry'; @@ -167,12 +167,11 @@ function agentProfileCatalogStub(): ISessionAgentProfileCatalog { }; } -function workspaceRegistryStub(): IWorkspaceRegistry { +function workspaceStub(): IWorkspaceService { return { _serviceBrand: undefined, list: () => Promise.resolve([]), get: () => Promise.resolve(undefined), - resolveAliasIds: (id) => Promise.resolve([id]), createOrTouch: (root, name) => Promise.resolve({ id: 'wd_stub', @@ -186,9 +185,9 @@ function workspaceRegistryStub(): IWorkspaceRegistry { }; } -function workspaceLocalConfigStub( +function projectLocalConfigStub( localDirs: readonly string[] = [], -): IWorkspaceLocalConfigService { +): IProjectLocalConfigService { return { _serviceBrand: undefined, readAdditionalDirs: (workDir: string) => @@ -203,13 +202,12 @@ function workspaceLocalConfigStub( }; } -function persistentWorkspaceRegistryStub(): IWorkspaceRegistry { +function persistentWorkspaceStub(): IWorkspaceService { const workspaces = new Map(); return { _serviceBrand: undefined, list: () => Promise.resolve([...workspaces.values()]), get: (id) => Promise.resolve(workspaces.get(id)), - resolveAliasIds: (id) => Promise.resolve([id]), createOrTouch: (root, name) => { const id = encodeWorkDirKey(root); const now = 1; @@ -465,7 +463,7 @@ describe('SessionLifecycleService', () => { stubPair(ISessionSkillCatalog, skillCatalogStub()), stubPair(ISessionToolPolicy, sessionToolPolicyStub()), stubPair(ISessionAgentProfileCatalog, agentProfileCatalogStub()), - stubPair(IWorkspaceRegistry, workspaceRegistryStub()), + stubPair(IWorkspaceService, workspaceStub()), stubPair(ISessionIndex, sessionIndexStub()), stubPair(IAppendLogStore, appendLogStoreStub()), stubPair(IAtomicDocumentStore, atomicDocumentStoreStub()), @@ -474,7 +472,7 @@ describe('SessionLifecycleService', () => { stubPair(ISessionMcpService, sessionMcpServiceStub()), stubPair(IConfigService, configStub()), stubPair(ISessionCronService, { _serviceBrand: undefined } as unknown as ISessionCronService), - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub()), + stubPair(IProjectLocalConfigService, projectLocalConfigStub()), stubPair(ITelemetryService, recordingTelemetry(telemetryRecords)), stubPair(ICronTaskPersistence, cronStoreStub()), ...extra, @@ -584,8 +582,8 @@ describe('SessionLifecycleService', () => { appended.push({ scope, key, record }); }, }), - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + stubPair(IWorkspaceService, { + ...workspaceStub(), // As the real registry does after folding: the id minted for the // first-seen spelling is reused for the alias. createOrTouch: (root: string, name?: string) => @@ -617,22 +615,22 @@ describe('SessionLifecycleService', () => { it('registers the workspace during create so a cold resume can resolve the workdir', async () => { const workDir = '/tmp/proj'; - const workspaceRegistry = persistentWorkspaceRegistryStub(); + const workspaces = persistentWorkspaceStub(); const sessionIndex = sessionIndexWithSummary('s1', workDir); const first = build([ - stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), ]); await first.create({ sessionId: 's1', workDir }); - await expect(workspaceRegistry.get(encodeWorkDirKey(workDir))).resolves.toMatchObject({ + await expect(workspaces.get(encodeWorkDirKey(workDir))).resolves.toMatchObject({ root: workDir, }); host?.dispose(); host = undefined; const second = build([ - stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndex), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); @@ -645,7 +643,7 @@ describe('SessionLifecycleService', () => { it('resumes from the persisted cwd when the workspace registry entry is missing', async () => { const workDir = '/tmp/proj'; const svc = build([ - stubPair(IWorkspaceRegistry, persistentWorkspaceRegistryStub()), + stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); @@ -674,7 +672,7 @@ describe('SessionLifecycleService', () => { const workDir = '/tmp/proj'; const staleRoot = '/tmp/stale'; const indexedWorkspaceId = 'wd_indexed'; - const workspaceRegistry: IWorkspaceRegistry = { + const workspaces: IWorkspaceService = { _serviceBrand: undefined, list: () => Promise.resolve([]), get: (id) => @@ -689,8 +687,7 @@ describe('SessionLifecycleService', () => { } : undefined, ), - resolveAliasIds: (id) => Promise.resolve([id]), - createOrTouch: (root, name) => + createOrTouch: (root, name) => Promise.resolve({ id: encodeWorkDirKey(root), root, @@ -702,7 +699,7 @@ describe('SessionLifecycleService', () => { delete: () => Promise.resolve(), }; const svc = build([ - stubPair(IWorkspaceRegistry, workspaceRegistry), + stubPair(IWorkspaceService, workspaces), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir, indexedWorkspaceId)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); @@ -800,8 +797,8 @@ describe('SessionLifecycleService', () => { dispose: () => {}, } as unknown as IAgentScopeHandle; const svc = build([ - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + stubPair(IWorkspaceService, { + ...workspaceStub(), get: () => Promise.resolve({ id: 'wd_stub', @@ -847,7 +844,7 @@ describe('SessionLifecycleService', () => { it('emits session_started with resumed: true and the bound session id on resume', async () => { const workDir = '/tmp/proj'; const svc = build([ - stubPair(IWorkspaceRegistry, persistentWorkspaceRegistryStub()), + stubPair(IWorkspaceService, persistentWorkspaceStub()), stubPair(ISessionIndex, sessionIndexWithSummary('s1', workDir)), stubPair(IAgentLifecycleService, agentLifecycleWithMainStub()), ]); @@ -996,7 +993,7 @@ describe('SessionLifecycleService', () => { it('loads project-local additional dirs into the session workspace on create', async () => { const svc = build([ - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), + stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), ]); const h = await svc.create({ sessionId: 's1', workDir: '/tmp/proj' }); expect(dirsOf(h)).toEqual(['/tmp/extra']); @@ -1014,7 +1011,7 @@ describe('SessionLifecycleService', () => { it('deduplicates project-local and caller dirs after resolving', async () => { const svc = build([ - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/shared'])), + stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/shared'])), ]); const h = await svc.create({ sessionId: 's1', @@ -1026,7 +1023,7 @@ describe('SessionLifecycleService', () => { it('supports multiple project-local and caller additionalDirs', async () => { const svc = build([ - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/a', '/tmp/b'])), + stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/a', '/tmp/b'])), ]); const h = await svc.create({ sessionId: 's1', @@ -1045,13 +1042,13 @@ describe('SessionLifecycleService', () => { } as unknown as IAgentScopeHandle; const summary = { id: 's1', workspaceId: 'wd_stub' } as SessionSummary; const svc = build([ - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), + stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), stubPair(ISessionIndex, { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + stubPair(IWorkspaceService, { + ...workspaceStub(), get: () => Promise.resolve({ id: 'wd_stub', @@ -1075,9 +1072,9 @@ describe('SessionLifecycleService', () => { it('fork inherits project-local dirs', async () => { const svc = build([ - stubPair(IWorkspaceLocalConfigService, workspaceLocalConfigStub(['/tmp/extra'])), - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + stubPair(IProjectLocalConfigService, projectLocalConfigStub(['/tmp/extra'])), + stubPair(IWorkspaceService, { + ...workspaceStub(), get: () => Promise.resolve({ id: 'wd_stub', @@ -1106,8 +1103,8 @@ describe('SessionLifecycleService', () => { it('fork mints a session_-prefixed lowercase id when newSessionId is omitted', async () => { const svc = build([ - stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + stubPair(IWorkspaceService, { + ...workspaceStub(), get: () => Promise.resolve({ id: 'wd_stub', @@ -1130,8 +1127,8 @@ describe('SessionLifecycleService', () => { describe('fork session state', () => { function workspaceGetStub(): ReturnType { - return stubPair(IWorkspaceRegistry, { - ...workspaceRegistryStub(), + return stubPair(IWorkspaceService, { + ...workspaceStub(), get: () => Promise.resolve({ id: 'wd_stub', @@ -1328,7 +1325,7 @@ describe('SessionLifecycleService', () => { ...sessionIndexStub(), get: () => Promise.resolve(summary), }), - stubPair(IWorkspaceRegistry, persistentWorkspaceRegistryStub()), + stubPair(IWorkspaceService, persistentWorkspaceStub()), ]); await svc.resume('s1'); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts b/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts similarity index 85% rename from packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts rename to packages/agent-core-v2/test/app/workspace/workspaceService.test.ts index 8532e9b589..cb0cb7015b 100644 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceRegistryService.test.ts +++ b/packages/agent-core-v2/test/app/workspace/workspaceService.test.ts @@ -19,10 +19,10 @@ import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDo import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; -import { IWorkspaceRegistry } from '#/app/workspaceRegistry/workspaceRegistry'; -import { WorkspaceRegistryService } from '#/app/workspaceRegistry/workspaceRegistryService'; -import { FileWorkspacePersistence } from '#/app/workspaceRegistry/fileWorkspacePersistence'; -import { IWorkspacePersistence, type PersistedWorkspaceEntry } from '#/app/workspaceRegistry/workspacePersistence'; +import { IWorkspaceService } from '#/app/workspace/workspace'; +import { WorkspaceService } from '#/app/workspace/workspaceService'; +import { FileWorkspacePersistence } from '#/app/workspace/fileWorkspacePersistence'; +import { IWorkspacePersistence, type PersistedWorkspaceEntry } from '#/app/workspace/workspacePersistence'; interface SessionIndexLine { readonly sessionId: string; @@ -30,7 +30,7 @@ interface SessionIndexLine { readonly workDir: string; } -describe('WorkspaceRegistryService (file-backed)', () => { +describe('WorkspaceService (file-backed)', () => { let homeDir: string; let currentHost: ReturnType | undefined; @@ -41,14 +41,14 @@ describe('WorkspaceRegistryService (file-backed)', () => { IWorkspacePersistence, FileWorkspacePersistence, InstantiationType.Delayed, - 'workspaceRegistry', + 'workspace', ); registerScopedService( LifecycleScope.App, - IWorkspaceRegistry, - WorkspaceRegistryService, + IWorkspaceService, + WorkspaceService, InstantiationType.Delayed, - 'workspaceRegistry', + 'workspace', ); homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-registry-')); }); @@ -59,7 +59,7 @@ describe('WorkspaceRegistryService (file-backed)', () => { await fsp.rm(homeDir, { recursive: true, force: true }); }); - function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceRegistry { + function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceService { const fileStorage = new FileStorageService(homeDir); const host = createScopedTestHost([ stubPair(IFileSystemStorageService, fileStorage), @@ -67,10 +67,10 @@ describe('WorkspaceRegistryService (file-backed)', () => { stubPair(IHostFileSystem, hostFs), ]); currentHost = host; - return host.app.accessor.get(IWorkspaceRegistry); + return host.app.accessor.get(IWorkspaceService); } - function restart(): IWorkspaceRegistry { + function restart(): IWorkspaceService { currentHost?.dispose(); currentHost = undefined; return build(); @@ -505,81 +505,8 @@ describe('WorkspaceRegistryService (file-backed)', () => { expect((await registry.list()).map((w) => w.root).toSorted()).toEqual(['/tmp/Foo', '/tmp/foo']); }); - it('resolveAliasIds returns every registered id for one physical directory', async () => { - // A legacy catalog holds two entries whose roots differ only by casing — - // one physical folder, two bucket ids (this is what `dedupeByRoot` merges - // for listing; the alias set exposes both for multi-bucket reads). - const lowerRoot = 'c:\\users\\foo\\proj'; - const typedRoot = 'C:\\Users\\Foo\\Proj'; - const legacyId = 'wd_proj_deadbeef0002'; - const canonicalId = encodeWorkDirKey(lowerRoot); - const entry = (root: string): PersistedWorkspaceEntry => ({ - root, - name: 'proj', - created_at: '2026-01-01T00:00:00.000Z', - last_opened_at: '2026-01-01T00:00:00.000Z', - }); - await writeWorkspacesJson({ - [legacyId]: entry(typedRoot), - [canonicalId]: entry(lowerRoot), - }); - - const registry = build(); - for (const id of [legacyId, canonicalId]) { - expect((await registry.resolveAliasIds(id)).toSorted()).toEqual( - [legacyId, canonicalId].toSorted(), - ); - } - }); - - it('resolveAliasIds folds in session-index-only spellings of the same root', async () => { - // The sibling bucket's spelling was never registered: only the legacy - // session index remembers it. Malformed index lines are skipped, never - // thrown. - const typedRoot = 'C:\\Users\\Foo\\Proj'; - const typedId = encodeWorkDirKey(typedRoot); - const indexOnlyId = encodeWorkDirKey('c:\\Users\\Foo\\Proj'); - await writeWorkspacesJson({ - [typedId]: { - root: typedRoot, - name: 'proj', - created_at: '2026-01-01T00:00:00.000Z', - last_opened_at: '2026-01-01T00:00:00.000Z', - }, - }); - await seedSessionIndex([ - { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: typedRoot }, - { sessionId: 's2', sessionDir: 'sessions/b/s2', workDir: 'c:\\Users\\Foo\\Proj' }, - { sessionId: 's3', sessionDir: 'sessions/c/s3', workDir: join(homeDir, 'unrelated') }, - ]); - await fsp.appendFile(join(homeDir, 'session_index.jsonl'), 'not-json\n{}\n', 'utf8'); - - const registry = build(); - expect((await registry.resolveAliasIds(typedId)).toSorted()).toEqual( - [typedId, indexOnlyId].toSorted(), - ); - }); - it('resolveAliasIds keeps unknown ids and POSIX roots singleton', async () => { - const root = join(homeDir, 'posix'); - const id = encodeWorkDirKey(root); - await writeWorkspacesJson({ - [id]: { - root, - name: 'posix', - created_at: '2026-01-01T00:00:00.000Z', - last_opened_at: '2026-01-01T00:00:00.000Z', - }, - }); - const registry = build(); - // Unknown id: callers keep their existing not-found semantics. - expect(await registry.resolveAliasIds('wd_missing_000000000000')).toEqual([ - 'wd_missing_000000000000', - ]); - // POSIX roots never fold, so the alias set is just the id itself. - expect(await registry.resolveAliasIds(id)).toEqual([id]); - }); it('delete tombstones every folded alias so a legacy split cannot resurface', async () => { // Split legacy state: two registered spellings of one Windows root, plus a diff --git a/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts new file mode 100644 index 0000000000..cd2951ab01 --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceAliases/workspaceAliasesService.test.ts @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { promises as fsp } from 'node:fs'; +import os from 'node:os'; +import { join } from 'node:path'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { encodeWorkDirKey } from '#/_base/utils/workdir-slug'; +import { HostFileSystem } from '#/os/backends/node-local/hostFsService'; +import { IHostFileSystem } from '#/os/interface/hostFileSystem'; +import { JsonAtomicDocumentStore } from '#/persistence/backends/node-fs/atomicDocumentStore'; +import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageService'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { IFileSystemStorageService } from '#/persistence/interface/storage'; +import { IWorkspaceService } from '#/app/workspace/workspace'; +import { WorkspaceService } from '#/app/workspace/workspaceService'; +import { FileWorkspacePersistence } from '#/app/workspace/fileWorkspacePersistence'; +import { + IWorkspacePersistence, + type PersistedWorkspaceEntry, +} from '#/app/workspace/workspacePersistence'; +import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases'; +import { WorkspaceAliasesService } from '#/app/workspaceAliases/workspaceAliasesService'; + +interface SessionIndexLine { + readonly sessionId: string; + readonly sessionDir: string; + readonly workDir: string; +} + +describe('WorkspaceAliasesService (file-backed)', () => { + let homeDir: string; + let currentHost: ReturnType | undefined; + + beforeEach(async () => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IWorkspacePersistence, + FileWorkspacePersistence, + InstantiationType.Delayed, + 'workspace', + ); + registerScopedService( + LifecycleScope.App, + IWorkspaceService, + WorkspaceService, + InstantiationType.Delayed, + 'workspace', + ); + registerScopedService( + LifecycleScope.App, + IWorkspaceAliases, + WorkspaceAliasesService, + InstantiationType.Delayed, + 'workspaceAliases', + ); + homeDir = await fsp.mkdtemp(join(os.tmpdir(), 'ws-aliases-')); + }); + + afterEach(async () => { + currentHost?.dispose(); + currentHost = undefined; + await fsp.rm(homeDir, { recursive: true, force: true }); + }); + + function build(hostFs: IHostFileSystem = new HostFileSystem()): IWorkspaceAliases { + const fileStorage = new FileStorageService(homeDir); + const host = createScopedTestHost([ + stubPair(IFileSystemStorageService, fileStorage), + stubPair(IAtomicDocumentStore, new JsonAtomicDocumentStore(fileStorage)), + stubPair(IHostFileSystem, hostFs), + ]); + currentHost = host; + return host.app.accessor.get(IWorkspaceAliases); + } + + async function seedSessionIndex(entries: SessionIndexLine[]): Promise { + const text = `${entries.map((e) => JSON.stringify(e)).join('\n')}\n`; + await fsp.writeFile(join(homeDir, 'session_index.jsonl'), text, 'utf8'); + } + + async function writeWorkspacesJson( + workspaces: Record, + extra?: { readonly deleted_workspace_ids?: unknown }, + ): Promise { + await fsp.writeFile( + join(homeDir, 'workspaces.json'), + JSON.stringify({ version: 1, workspaces, ...extra }), + 'utf8', + ); + } + + it('resolveAliasIds returns every registered id for one physical directory', async () => { + // A legacy catalog holds two entries whose roots differ only by casing — + // one physical folder, two bucket ids (this is what `dedupeByRoot` merges + // for listing; the alias set exposes both for multi-bucket reads). + const lowerRoot = 'c:\\users\\foo\\proj'; + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const legacyId = 'wd_proj_deadbeef0002'; + const canonicalId = encodeWorkDirKey(lowerRoot); + const entry = (root: string): PersistedWorkspaceEntry => ({ + root, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }); + await writeWorkspacesJson({ + [legacyId]: entry(typedRoot), + [canonicalId]: entry(lowerRoot), + }); + + const aliases = build(); + for (const id of [legacyId, canonicalId]) { + expect((await aliases.resolveAliasIds(id)).toSorted()).toEqual( + [legacyId, canonicalId].toSorted(), + ); + } + }); + + it('resolveAliasIds folds in session-index-only spellings of the same root', async () => { + // The sibling bucket's spelling was never registered: only the legacy + // session index remembers it. Malformed index lines are skipped, never + // thrown. + const typedRoot = 'C:\\Users\\Foo\\Proj'; + const typedId = encodeWorkDirKey(typedRoot); + const indexOnlyId = encodeWorkDirKey('c:\\Users\\Foo\\Proj'); + await writeWorkspacesJson({ + [typedId]: { + root: typedRoot, + name: 'proj', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }); + await seedSessionIndex([ + { sessionId: 's1', sessionDir: 'sessions/a/s1', workDir: typedRoot }, + { sessionId: 's2', sessionDir: 'sessions/b/s2', workDir: 'c:\\Users\\Foo\\Proj' }, + { sessionId: 's3', sessionDir: 'sessions/c/s3', workDir: join(homeDir, 'unrelated') }, + ]); + await fsp.appendFile(join(homeDir, 'session_index.jsonl'), 'not-json\n{}\n', 'utf8'); + + const aliases = build(); + expect((await aliases.resolveAliasIds(typedId)).toSorted()).toEqual( + [typedId, indexOnlyId].toSorted(), + ); + }); + + it('resolveAliasIds keeps unknown ids and POSIX roots singleton', async () => { + const root = join(homeDir, 'posix'); + const id = encodeWorkDirKey(root); + await writeWorkspacesJson({ + [id]: { + root, + name: 'posix', + created_at: '2026-01-01T00:00:00.000Z', + last_opened_at: '2026-01-01T00:00:00.000Z', + }, + }); + + const aliases = build(); + // Unknown id: callers keep their existing not-found semantics. + expect(await aliases.resolveAliasIds('wd_missing_000000000000')).toEqual([ + 'wd_missing_000000000000', + ]); + // POSIX roots never fold, so the alias set is just the id itself. + expect(await aliases.resolveAliasIds(id)).toEqual([id]); + }); +}); diff --git a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts b/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts deleted file mode 100644 index e69143afbe..0000000000 --- a/packages/agent-core-v2/test/app/workspaceRegistry/workspaceQueryService.test.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { InstantiationType } from '#/_base/di/extensions'; -import { - LifecycleScope, - _clearScopedRegistryForTests, - registerScopedService, -} from '#/_base/di/scope'; -import { createScopedTestHost, stubPair } from '#/_base/di/test'; -import { ISessionIndex, type SessionListQuery, type SessionSummary } from '#/app/sessionIndex/sessionIndex'; -import { - IWorkspaceQueryService, - RECENT_SESSIONS_LIMIT, -} from '#/app/workspaceRegistry/workspaceQuery'; -import { WorkspaceQueryService } from '#/app/workspaceRegistry/workspaceQueryService'; - -class FakeSessionIndex implements ISessionIndex { - readonly _serviceBrand: undefined; - lastListQuery: SessionListQuery | undefined; - items: readonly SessionSummary[] = []; - - async list(query: SessionListQuery) { - this.lastListQuery = query; - return { items: this.items }; - } - - async get(_id: string): Promise { - return undefined; - } - - async countActive(_workspaceIds: readonly string[]): Promise { - return 0; - } -} - -describe('WorkspaceQueryService', () => { - let currentHost: ReturnType | undefined; - - beforeEach(() => { - _clearScopedRegistryForTests(); - registerScopedService( - LifecycleScope.App, - IWorkspaceQueryService, - WorkspaceQueryService, - InstantiationType.Delayed, - 'workspaceRegistry', - ); - }); - - afterEach(() => { - currentHost?.dispose(); - currentHost = undefined; - }); - - function build(): { query: IWorkspaceQueryService; index: FakeSessionIndex } { - const index = new FakeSessionIndex(); - const host = createScopedTestHost([stubPair(ISessionIndex, index)]); - currentHost = host; - return { query: host.app.accessor.get(IWorkspaceQueryService), index }; - } - - function summary(id: string, workspaceId: string, updatedAt: number): SessionSummary { - return { id, workspaceId, createdAt: updatedAt - 1, updatedAt, archived: false }; - } - - it('delegates to the session index with the workspace id and the recent limit', async () => { - const { query, index } = build(); - - await query.listRecentSessions('wd_abc'); - - expect(index.lastListQuery).toEqual({ - workspaceIds: ['wd_abc'], - limit: RECENT_SESSIONS_LIMIT, - }); - expect(RECENT_SESSIONS_LIMIT).toBe(20); - }); - - it('returns the index items for the workspace', async () => { - const { query, index } = build(); - const items = [summary('s2', 'wd_abc', 200), summary('s1', 'wd_abc', 100)]; - index.items = items; - - await expect(query.listRecentSessions('wd_abc')).resolves.toEqual(items); - }); - - it('returns an empty array when the workspace has no sessions', async () => { - const { query } = build(); - - await expect(query.listRecentSessions('wd_empty')).resolves.toEqual([]); - }); -}); diff --git a/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts new file mode 100644 index 0000000000..ce5b0f7eda --- /dev/null +++ b/packages/agent-core-v2/test/app/workspaceSessions/workspaceSessionsService.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { InstantiationType } from '#/_base/di/extensions'; +import { + LifecycleScope, + _clearScopedRegistryForTests, + registerScopedService, +} from '#/_base/di/scope'; +import { createScopedTestHost, stubPair } from '#/_base/di/test'; +import { + ISessionIndex, + type SessionListQuery, + type SessionSummary, +} from '#/app/sessionIndex/sessionIndex'; +import { IWorkspaceAliases } from '#/app/workspaceAliases/workspaceAliases'; +import { + IWorkspaceSessions, + RECENT_SESSIONS_LIMIT, +} from '#/app/workspaceSessions/workspaceSessions'; +import { WorkspaceSessionsService } from '#/app/workspaceSessions/workspaceSessionsService'; + +class FakeSessionIndex implements ISessionIndex { + readonly _serviceBrand: undefined; + lastListQuery: SessionListQuery | undefined; + listQueries: SessionListQuery[] = []; + items: readonly SessionSummary[] = []; + + async list(query: SessionListQuery) { + this.lastListQuery = query; + this.listQueries.push(query); + return { items: this.items }; + } + + async get(_id: string): Promise { + return undefined; + } + + async countActive(_workspaceIds: readonly string[]): Promise { + return 0; + } +} + +class FakeWorkspaceAliases implements IWorkspaceAliases { + readonly _serviceBrand: undefined; + aliases: Record = {}; + + resolveAliasIds(id: string): Promise { + return Promise.resolve(this.aliases[id] ?? [id]); + } +} + +describe('WorkspaceSessionsService', () => { + let currentHost: ReturnType | undefined; + + beforeEach(() => { + _clearScopedRegistryForTests(); + registerScopedService( + LifecycleScope.App, + IWorkspaceSessions, + WorkspaceSessionsService, + InstantiationType.Delayed, + 'workspaceSessions', + ); + }); + + afterEach(() => { + currentHost?.dispose(); + currentHost = undefined; + }); + + function build(): { + sessions: IWorkspaceSessions; + index: FakeSessionIndex; + aliases: FakeWorkspaceAliases; + } { + const index = new FakeSessionIndex(); + const aliases = new FakeWorkspaceAliases(); + const host = createScopedTestHost([ + stubPair(ISessionIndex, index), + stubPair(IWorkspaceAliases, aliases), + ]); + currentHost = host; + return { sessions: host.app.accessor.get(IWorkspaceSessions), index, aliases }; + } + + function summary(id: string, workspaceId: string, updatedAt: number): SessionSummary { + return { id, workspaceId, createdAt: updatedAt - 1, updatedAt, archived: false }; + } + + it('listRecent delegates with the folded alias set and the recent limit', async () => { + const { sessions, index, aliases } = build(); + aliases.aliases['wd_abc'] = ['wd_abc', 'wd_abc_legacy']; + + await sessions.listRecent('wd_abc'); + + expect(index.lastListQuery).toEqual({ + workspaceIds: ['wd_abc', 'wd_abc_legacy'], + limit: RECENT_SESSIONS_LIMIT, + }); + expect(RECENT_SESSIONS_LIMIT).toBe(20); + }); + + it('listRecent returns the index items for the workspace', async () => { + const { sessions, index } = build(); + const items = [summary('s2', 'wd_abc', 200), summary('s1', 'wd_abc', 100)]; + index.items = items; + + await expect(sessions.listRecent('wd_abc')).resolves.toEqual(items); + }); + + it('listRecent returns an empty array when the workspace has no sessions', async () => { + const { sessions } = build(); + + await expect(sessions.listRecent('wd_empty')).resolves.toEqual([]); + }); + + it('count folds aliases and includes archived sessions', async () => { + const { sessions, index, aliases } = build(); + aliases.aliases['wd_abc'] = ['wd_abc', 'wd_abc_legacy']; + index.items = [ + summary('s3', 'wd_abc_legacy', 300), + summary('s2', 'wd_abc', 200), + summary('s1', 'wd_abc', 100), + ]; + + await expect(sessions.count('wd_abc')).resolves.toBe(3); + expect(index.lastListQuery).toEqual({ + workspaceIds: ['wd_abc', 'wd_abc_legacy'], + includeArchived: true, + }); + }); + + it('count returns 0 when the workspace has no sessions', async () => { + const { sessions } = build(); + + await expect(sessions.count('wd_empty')).resolves.toBe(0); + }); +}); diff --git a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts index f43d2763fc..4167e70c83 100644 --- a/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts +++ b/packages/agent-core-v2/test/session/workspaceCommand/workspaceCommand.test.ts @@ -9,14 +9,14 @@ import { Emitter } from '#/_base/event'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import type { ContextMessage } from '#/agent/contextMemory/types'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; -import { IWorkspaceLocalConfigService } from '#/app/workspaceLocalConfig/workspaceLocalConfig'; +import { IProjectLocalConfigService } from '#/app/projectLocalConfig/projectLocalConfig'; import { ErrorCodes, Error2 } from '#/errors'; import { type HostDirEntry, type HostFileStat, IHostFileSystem, } from '#/os/interface/hostFileSystem'; -import { FileWorkspaceLocalConfigService } from '#/persistence/backends/node-fs/workspaceLocalConfigService'; +import { FileProjectLocalConfigService } from '#/persistence/backends/node-fs/projectLocalConfigService'; import { IAgentLifecycleService, MAIN_AGENT_ID, @@ -252,7 +252,7 @@ describe('SessionWorkspaceCommandService', () => { reg.define(ISessionWorkspaceContext, SessionWorkspaceContextService); reg.defineInstance(IBootstrapService, bootstrapStub()); reg.defineInstance(IHostFileSystem, fs); - reg.define(IWorkspaceLocalConfigService, FileWorkspaceLocalConfigService); + reg.define(IProjectLocalConfigService, FileProjectLocalConfigService); reg.defineInstance(IAgentLifecycleService, agents); reg.define(ISessionWorkspaceCommandService, SessionWorkspaceCommandService); }, diff --git a/packages/kap-server/src/routes/sessions.ts b/packages/kap-server/src/routes/sessions.ts index e47a0f7637..46869f6fa0 100644 --- a/packages/kap-server/src/routes/sessions.ts +++ b/packages/kap-server/src/routes/sessions.ts @@ -65,9 +65,9 @@ * **cwd resolution (gap G3 closed)**: the session's frozen work dir is * persisted on its metadata document (`ISessionMetadata`) and surfaced on the * `ISessionIndex` summary, so `metadata.cwd` comes from the session itself — - * not from `IWorkspaceRegistry`. Sessions whose workspace was unregistered keep + * not from `IWorkspaceService`. Sessions whose workspace was unregistered keep * their original cwd and stay listed / gettable (matching v1, which stores - * `workDir` on the session). `IWorkspaceRegistry` is consulted only as a + * `workDir` on the session). `IWorkspaceService` is consulted only as a * back-compat fallback for sessions written before `cwd` was persisted. */ @@ -89,7 +89,8 @@ import { ISessionMetadata, ISessionLegacyService, IEventService, - IWorkspaceRegistry, + IWorkspaceAliases, + IWorkspaceService, isError2, Error2, toProtocolMessage, @@ -274,7 +275,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } - const registry = core.accessor.get(IWorkspaceRegistry); + const registry = core.accessor.get(IWorkspaceService); let workDir: string; if (workspaceId !== undefined) { const workspace = await registry.get(workspaceId); @@ -358,7 +359,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const pageSize = raw.page_size; const archivedOnly = raw.archived_only === true; - const workspaces = await core.accessor.get(IWorkspaceRegistry).list(); + const workspaces = await core.accessor.get(IWorkspaceService).list(); const roots = new Map(workspaces.map((w) => [w.id, w.root])); // v1 resolves `workspace_id` to its root and 40410s when it is unknown; @@ -385,7 +386,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void const workspaceIds = raw.workspace_id === undefined ? undefined - : await core.accessor.get(IWorkspaceRegistry).resolveAliasIds(raw.workspace_id); + : await core.accessor.get(IWorkspaceAliases).resolveAliasIds(raw.workspace_id); const page = await core.accessor.get(ISessionIndex).list({ workspaceIds, includeArchived: archivedOnly ? true : raw.include_archive, @@ -485,7 +486,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } const cwd = - summary.cwd ?? (await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root; + summary.cwd ?? (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; if (cwd === undefined) { // Persisted session with no `cwd` on disk and no registered workspace // to fall back to (predates gap-G3 persistence) — cannot project cwd. @@ -532,7 +533,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void return; } const cwd = - summary.cwd ?? (await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId))?.root; + summary.cwd ?? (await core.accessor.get(IWorkspaceService).get(summary.workspaceId))?.root; if (cwd === undefined) { reply.send( errEnvelope( @@ -838,7 +839,7 @@ export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void // registry is only a back-compat fallback for sessions written before // `cwd` was persisted, defaulting to '' (matches the prior adapter). const roots = new Map( - (await core.accessor.get(IWorkspaceRegistry).list()).map((w) => [w.id, w.root]), + (await core.accessor.get(IWorkspaceService).list()).map((w) => [w.id, w.root]), ); const projected = window.map((summary) => toWireSession( diff --git a/packages/kap-server/src/routes/skills.ts b/packages/kap-server/src/routes/skills.ts index 861056a38d..7c0a1045b2 100644 --- a/packages/kap-server/src/routes/skills.ts +++ b/packages/kap-server/src/routes/skills.ts @@ -16,7 +16,7 @@ * counterpart: it scans the same roots a new session in that workspace cwd * would, so clients can populate the composer skill menu before a session * exists. The workspace id is resolved to its root via - * `IWorkspaceRegistry.get` (`40410` when unknown); the root is then scanned by + * `IWorkspaceService.get` (`40410` when unknown); the root is then scanned by * composing the same five sources the per-session catalog merges — builtin / * user / extra / project(workDir) / plugin — through the shared `ISkillDiscovery`, * `skillRoots` and `InMemorySkillCatalog` primitives, so the result matches the @@ -35,7 +35,7 @@ * **Scope split**: v1 resolves a single `ISkillService` for every verb. v2 * splits the domain, so the route borrows different scoped services per verb: * - session list → `ISessionSkillCatalog` (Session scope) — `catalog.listSkills()`. - * - workspace list → no session: resolves `IWorkspaceRegistry` (App scope) + * - workspace list → no session: resolves `IWorkspaceService` (App scope) * for the root, then composes the skill scan at the edge (see above). * - activate → `IAgentSkillService` (Agent scope, on the `main` agent) — * renders the skill prompt and starts a turn with a @@ -83,7 +83,7 @@ import { ISessionSkillCatalog, ISkillCatalogRuntimeOptions, ISkillDiscovery, - IWorkspaceRegistry, + IWorkspaceService, InMemorySkillCatalog, isError2, MERGE_ALL_AVAILABLE_SKILLS_SECTION, @@ -222,7 +222,7 @@ export function registerSkillsRoutes(app: SkillsRouteHost, core: Scope): void { }, async (req, reply) => { const { workspace_id } = req.params; - const ws = await core.accessor.get(IWorkspaceRegistry).get(workspace_id); + const ws = await core.accessor.get(IWorkspaceService).get(workspace_id); if (ws === undefined) { reply.send( errEnvelope( diff --git a/packages/kap-server/src/routes/snapshot.ts b/packages/kap-server/src/routes/snapshot.ts index c3e7b3eb7c..ee75e8bf64 100644 --- a/packages/kap-server/src/routes/snapshot.ts +++ b/packages/kap-server/src/routes/snapshot.ts @@ -27,7 +27,7 @@ import { ISessionContext, ISessionLifecycleService, ISessionMetadata, - IWorkspaceRegistry, + IWorkspaceService, toProtocolMessage, type IAgentScopeHandle, type Scope, @@ -161,7 +161,7 @@ async function readViaLegacyAssembly( // `version` → ISO-string timestamps → epoch ms, id backfilled), so the // metadata read here is always v2-shaped and safe to project. const workspaceId = handle.accessor.get(ISessionContext).workspaceId; - const workspace = await core.accessor.get(IWorkspaceRegistry).get(workspaceId); + const workspace = await core.accessor.get(IWorkspaceService).get(workspaceId); const cwd = workspace?.root ?? ''; const meta = await handle.accessor.get(ISessionMetadata).read(); const session = toWireSession( diff --git a/packages/kap-server/src/routes/workspaces.ts b/packages/kap-server/src/routes/workspaces.ts index dd8341cf82..f6d9aca891 100644 --- a/packages/kap-server/src/routes/workspaces.ts +++ b/packages/kap-server/src/routes/workspaces.ts @@ -2,9 +2,9 @@ * `/workspaces` route handlers — server-v2 port. * * Implements the v1 `/api/v1/workspaces` wire contract on top of - * `agent-core-v2` services. Backed by `IWorkspaceRegistry` (Core scope) for the + * `agent-core-v2` services. Backed by `IWorkspaceService` (App scope) for the * catalog, `IHostFileSystem` to validate roots, and - * `ISessionIndex` to derive `session_count`. + * `IWorkspaceSessions` to derive `session_count`. * * GET /workspaces list * POST /workspaces register (idempotent on root) @@ -17,14 +17,15 @@ * - `created_at` / `last_opened_at` — from the registry's in-memory * timestamps (reset on restart; the registry is still a skeleton). * - `session_count` — count of persisted sessions for the workspace, summed - * across every id spelling of the same root (`resolveAliasIds`) so legacy - * split buckets count once for the workspace, not per bucket. + * across every id spelling of the same root (`IWorkspaceSessions.count` + * folds the alias set) so legacy split buckets count once for the + * workspace, not per bucket. */ import { IHostFileSystem, - ISessionIndex, - IWorkspaceRegistry, + IWorkspaceService, + IWorkspaceSessions, type Scope, type Workspace, } from '@moonshot-ai/agent-core-v2'; @@ -94,7 +95,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): tags: ['workspaces'], }, async (req, reply) => { - const items = await core.accessor.get(IWorkspaceRegistry).list(); + const items = await core.accessor.get(IWorkspaceService).list(); const projected = await Promise.all(items.map((ws) => toWireWorkspace(core, ws))); reply.send(okEnvelope({ items: projected }, req.id)); }, @@ -138,7 +139,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): reply.send(errEnvelope(ErrorCode.FS_PATH_NOT_FOUND, `root ${root} does not exist`, req.id)); return; } - const ws = await core.accessor.get(IWorkspaceRegistry).createOrTouch(root, req.body.name); + const ws = await core.accessor.get(IWorkspaceService).createOrTouch(root, req.body.name); reply.send(okEnvelope(await toWireWorkspace(core, ws), req.id)); }, ); @@ -165,7 +166,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): async (req, reply) => { const { workspace_id } = req.params; const ws = await core.accessor - .get(IWorkspaceRegistry) + .get(IWorkspaceService) .update(workspace_id, { name: req.body.name }); if (ws === undefined) { reply.send( @@ -197,7 +198,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): }, async (req, reply) => { const { workspace_id } = req.params; - const registry = core.accessor.get(IWorkspaceRegistry); + const registry = core.accessor.get(IWorkspaceService); const existing = await registry.get(workspace_id); if (existing === undefined) { reply.send( @@ -222,7 +223,7 @@ export function registerWorkspacesRoutes(app: WorkspaceRouteHost, core: Scope): // --------------------------------------------------------------------------- async function toWireWorkspace(core: Scope, ws: Workspace): Promise { - const sessionCount = await countSessions(core, ws.id); + const sessionCount = await core.accessor.get(IWorkspaceSessions).count(ws.id); return { id: ws.id, root: ws.root, @@ -233,16 +234,6 @@ async function toWireWorkspace(core: Scope, ws: Workspace): Promise { - // One set-query over the alias set (legacy split buckets): a single merged - // listing cannot double-count, and a singleton set behaves exactly as before. - const workspaceIds = await core.accessor.get(IWorkspaceRegistry).resolveAliasIds(workspaceId); - const page = await core.accessor - .get(ISessionIndex) - .list({ workspaceIds, includeArchived: true }); - return page.items.length; -} - function buildValidationEnvelope( details: { path: string; message: string }[], requestId: string, diff --git a/packages/kap-server/src/services/snapshot/snapshotReader.ts b/packages/kap-server/src/services/snapshot/snapshotReader.ts index 0b2f14758e..0723f10f9f 100644 --- a/packages/kap-server/src/services/snapshot/snapshotReader.ts +++ b/packages/kap-server/src/services/snapshot/snapshotReader.ts @@ -27,7 +27,7 @@ import { ISessionIndex, ISessionInteractionService, ISessionLifecycleService, - IWorkspaceRegistry, + IWorkspaceService, normalizeSessionMeta, reduceContextTranscript, toProtocolMessage, @@ -157,7 +157,7 @@ export class SnapshotReader implements ISnapshotReader { const { core, homeDir } = this.deps; const summary = await core.accessor.get(ISessionIndex).get(sid); if (summary === undefined) throw new SnapshotNotFoundError(sid); - const workspace = await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId); + const workspace = await core.accessor.get(IWorkspaceService).get(summary.workspaceId); if (workspace === undefined) throw new SnapshotNotFoundError(sid); const sessionDir = join(homeDir, SESSIONS_ROOT, summary.workspaceId, sid); diff --git a/packages/klient/src/contract/global/workspaces.ts b/packages/klient/src/contract/global/workspaces.ts index d5577f2c19..ac70a5a995 100644 --- a/packages/klient/src/contract/global/workspaces.ts +++ b/packages/klient/src/contract/global/workspaces.ts @@ -1,6 +1,6 @@ /** - * `workspaceRegistry` — process-wide catalog of known workspaces. Mirrors - * `agent-core-v2/app/workspaceRegistry/workspaceRegistry.ts`. + * `workspaceService` — process-wide catalog of known workspaces. Mirrors + * `agent-core-v2/app/workspace/workspace.ts`. */ import { z } from 'zod'; diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts index 1ae5760123..c708902d12 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -37,7 +37,7 @@ import { sessionQuestionContract } from './session/question.js'; export const globalContract: KlientContract = { // core (app scope) sessionIndex: sessionsContract, - workspaceRegistry: workspacesContract, + workspaceService: workspacesContract, configService: configContract, providerService: providersContract, modelService: modelsContract, diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts index 91bac1e2e4..0d1d83f821 100644 --- a/packages/klient/src/core/facade/global.ts +++ b/packages/klient/src/core/facade/global.ts @@ -15,7 +15,7 @@ import type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/quer import type { Workspace, WorkspaceUpdate, -} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; import type { ConfigDiagnostic, ConfigInspectValue, @@ -270,13 +270,13 @@ export function createGlobalFacade(scoped: ScopedCaller): GlobalFacade { }, workspaces: { - list: () => call('workspaceRegistry', 'list', []) as Promise, - get: (id) => call('workspaceRegistry', 'get', [id]) as Promise, + list: () => call('workspaceService', 'list', []) as Promise, + get: (id) => call('workspaceService', 'get', [id]) as Promise, createOrTouch: ({ root, name }) => - call('workspaceRegistry', 'createOrTouch', [root, name]) as Promise, + call('workspaceService', 'createOrTouch', [root, name]) as Promise, update: ({ id, patch }) => - call('workspaceRegistry', 'update', [id, patch]) as Promise, - delete: (id) => call('workspaceRegistry', 'delete', [id]) as Promise, + call('workspaceService', 'update', [id, patch]) as Promise, + delete: (id) => call('workspaceService', 'delete', [id]) as Promise, }, config: { diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index 5e6abb9f6a..3fb6521ade 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -87,7 +87,7 @@ export type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/quer export type { Workspace, WorkspaceUpdate, -} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; export type { ConfigDiagnostic, ConfigInspectValue, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index 3b2880c3a9..bf592ab309 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -7,7 +7,7 @@ import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceRegistry } from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +import { IWorkspaceService } from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; import { IModelService } from '@moonshot-ai/agent-core-v2/kosong/model/model'; import { IModelCatalog } from '@moonshot-ai/agent-core-v2/kosong/model/catalog'; @@ -38,7 +38,7 @@ import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage /** Wire service name (decorator id string) → token. */ export const serviceTokens: Readonly>> = { sessionIndex: ISessionIndex, - workspaceRegistry: IWorkspaceRegistry, + workspaceService: IWorkspaceService, configService: IConfigService, modelService: IModelService, modelResolver: IModelCatalog, diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 04cec36ca2..b1f6ac4b54 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -111,7 +111,7 @@ import type { import type { Workspace, WorkspaceUpdate, -} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +} from '@moonshot-ai/agent-core-v2/app/workspace/workspace'; // Test-only: `@moonshot-ai/protocol` is a devDependency; importing its types // here (never in `src/`) strengthens parity for the agent event stream. import type { diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts index 2dc3559564..f6da8bdc0c 100644 --- a/packages/klient/test/facade.test.ts +++ b/packages/klient/test/facade.test.ts @@ -65,7 +65,7 @@ describe('facade routing', () => { channel.result = { id: 'w1', root: '/x', name: 'n', createdAt: 1, lastOpenedAt: 2 }; await klient.global.workspaces.createOrTouch({ root: '/x', name: 'n' }); expect(channel.calls[0]).toMatchObject({ - service: 'workspaceRegistry', + service: 'workspaceService', method: 'createOrTouch', args: ['/x', 'n'], }); From abdbe0f3c2f484175f228e13e0dd43467416e96a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Tue, 21 Jul 2026 21:33:05 +0800 Subject: [PATCH 04/17] feat(transcript): add op-batch sequencing and point-to-point catch-up - wire: transcriptSeqSchema with per-agent batch seq watermark on transcript.reset/ops and the REST transcript response, the transcript_since subscription cursor, and the GET transcript/ops catch-up shape; seq stays optional everywhere so legacy peers fall back to loss-signal-driven refreshes - wire: drop interactionFrame, interactions live on the interaction item in the ops stream - kap-server: TranscriptService assigns consecutive per-agent batch seqs and retains them in a bounded in-memory journal; the transcript_since cursor replays journaled batches instead of a baseline reset, and the baseline reset is now items-empty because history always pages in over REST - kimi-inspect: transcript REST/WS clients track the op-batch watermark and run seq-gap/reconnect catch-up with full-refresh fallback; add the Transcript audit panel (AuditTrail timeline, structural diff, state tree) docked right of the chat - docs: sync AGENTS.md and agent-core-dev skill notes with the new transcript contract --- .agents/skills/agent-core-dev/design.md | 2 +- .../agent-core-dev/domain-boundaries.md | 4 +- .../skills/agent-core-dev/edge-exposure.md | 10 +- .agents/skills/agent-core-dev/orient.md | 2 +- .agents/skills/agent-core-dev/server-align.md | 2 +- .changeset/slim-reset-baseline.md | 5 + AGENTS.md | 6 +- apps/kimi-inspect/src/audit/audit.test.ts | 219 ++++++ apps/kimi-inspect/src/audit/diff.ts | 120 ++++ apps/kimi-inspect/src/audit/serialize.ts | 48 ++ apps/kimi-inspect/src/audit/trail.ts | 171 +++++ apps/kimi-inspect/src/audit/truncate.ts | 14 + apps/kimi-inspect/src/components/ChatView.tsx | 623 +++++++++++++----- apps/kimi-inspect/src/components/Sidebar.tsx | 6 +- .../src/components/audit/AuditPanel.tsx | 187 ++++++ .../src/components/audit/StateTree.test.tsx | 100 +++ .../src/components/audit/StateTree.tsx | 176 +++++ apps/kimi-inspect/src/transcript/api.ts | 70 +- apps/kimi-inspect/src/transcript/store.ts | 2 + .../src/transcript/transcript.test.ts | 179 ++++- apps/kimi-inspect/src/transcript/ws.ts | 66 +- packages/kap-server/src/routes/transcript.ts | 86 ++- .../src/services/transcript/coreBinding.ts | 14 +- .../src/services/transcript/coreEventMap.ts | 88 +-- .../services/transcript/transcriptService.ts | 102 ++- packages/kap-server/src/start.ts | 6 +- .../ws/v1/sessionEventBroadcaster.ts | 83 ++- .../src/transport/ws/v1/wsConnectionV1.ts | 25 +- .../apiSurface.snapshot.test.ts.snap | 4 + packages/kap-server/test/rpc.test.ts | 16 +- .../test/services/transcript.test.ts | 136 +++- .../test/sessionEventBroadcaster.test.ts | 159 ++++- packages/kap-server/test/snapshot.test.ts | 4 +- .../test/snapshotReader.unit.test.ts | 4 +- packages/kap-server/test/transcript.test.ts | 99 +++ .../kap-server/test/wsConnectionV1.test.ts | 43 +- packages/transcript/src/history/groupTurns.ts | 6 +- packages/transcript/src/model/frame.ts | 33 +- packages/transcript/src/model/interaction.ts | 20 +- packages/transcript/src/ops/apply.ts | 44 +- packages/transcript/src/wire/events.ts | 23 +- packages/transcript/src/wire/schema.ts | 63 +- packages/transcript/test/store.test.ts | 21 +- 43 files changed, 2634 insertions(+), 457 deletions(-) create mode 100644 .changeset/slim-reset-baseline.md create mode 100644 apps/kimi-inspect/src/audit/audit.test.ts create mode 100644 apps/kimi-inspect/src/audit/diff.ts create mode 100644 apps/kimi-inspect/src/audit/serialize.ts create mode 100644 apps/kimi-inspect/src/audit/trail.ts create mode 100644 apps/kimi-inspect/src/audit/truncate.ts create mode 100644 apps/kimi-inspect/src/components/audit/AuditPanel.tsx create mode 100644 apps/kimi-inspect/src/components/audit/StateTree.test.tsx create mode 100644 apps/kimi-inspect/src/components/audit/StateTree.tsx diff --git a/.agents/skills/agent-core-dev/design.md b/.agents/skills/agent-core-dev/design.md index 8bdb217622..c9b4c2f0c5 100644 --- a/.agents/skills/agent-core-dev/design.md +++ b/.agents/skills/agent-core-dev/design.md @@ -243,7 +243,7 @@ domain: `sessionLifecycle` (owning scope: App) ├─ hostEnvironment @App direct — gates scope creation on the probe ├─ sessionIndex @App direct — persisted read model for cold resumes ├─ storage @App direct — atomic docs + append logs - ├─ workspaceRegistry @App direct — resolves a session's workspace + ├─ workspace @App direct — resolves a session's workspace └─ event @App direct — broadcasts session-level facts (e.g. archived) ``` diff --git a/.agents/skills/agent-core-dev/domain-boundaries.md b/.agents/skills/agent-core-dev/domain-boundaries.md index 0f7236e7a6..c2f62cc9c2 100644 --- a/.agents/skills/agent-core-dev/domain-boundaries.md +++ b/.agents/skills/agent-core-dev/domain-boundaries.md @@ -46,7 +46,7 @@ Before introducing `I{Domain}EntityService`, classify the persistence model: | **Append-log / event-sourced** | The authoritative record is "what happened" | `wireRecord`, `contextMemory`, `goal`, `plan`, `permission` transitions | | **Blob / key-value** | Large or content-addressed bytes | media offload, blob store | | **Indexed query / read model** | Derived, queryable view | `sessionIndex`, future `IQueryStore` projections | -| **Registry / catalog** | Global or scoped known items | `workspaceRegistry`, `toolRegistry` | +| **Registry / catalog** | Global or scoped known items | `workspace`, `toolRegistry` | | **Ephemeral runtime state** | No durable entity | active turn handle, pending interactions, terminal handles | See [persistence.md](persistence.md) for the `Store → Storage → backend` rules. A domain EntityService is a business facade over those stores; it is not a replacement for the store layer. @@ -101,7 +101,7 @@ The `session` domain owns only Session-level identity, metadata, lifecycle comma | Background tasks | `background` | | Cron tasks | `cron` | | Pending approvals / questions | `interaction` / `approval` / `question` | -| Workspace | `workspaceRegistry` | +| Workspace | `workspace` | | Provider / config | `provider` / `config` | Entity-service conclusion for `session`: diff --git a/.agents/skills/agent-core-dev/edge-exposure.md b/.agents/skills/agent-core-dev/edge-exposure.md index 1cc6b25be6..738326d1ac 100644 --- a/.agents/skills/agent-core-dev/edge-exposure.md +++ b/.agents/skills/agent-core-dev/edge-exposure.md @@ -56,11 +56,11 @@ Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`. | `sessions` | `list` | ISessionIndex.list | GET | | `sessions` | `get` | ISessionIndex.get | GET | | `sessions` | `countActive` | ISessionIndex.countActive | GET | -| `workspaces` | `list` | IWorkspaceRegistry.list | GET | -| `workspaces` | `get` | IWorkspaceRegistry.get | GET | -| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST | -| `workspaces` | `update` | IWorkspaceRegistry.update | POST | -| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST | +| `workspaces` | `list` | IWorkspaceService.list | GET | +| `workspaces` | `get` | IWorkspaceService.get | GET | +| `workspaces` | `createOrTouch` | IWorkspaceService.createOrTouch | POST | +| `workspaces` | `update` | IWorkspaceService.update | POST | +| `workspaces` | `delete` | IWorkspaceService.delete | POST | | `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET | | `config` | `set` / `replace` / `reload` | IConfigService.* | POST | | `providers` | `list` / `get` | IProviderService.* | GET | diff --git a/.agents/skills/agent-core-dev/orient.md b/.agents/skills/agent-core-dev/orient.md index 3047c43d45..402ff9304b 100644 --- a/.agents/skills/agent-core-dev/orient.md +++ b/.agents/skills/agent-core-dev/orient.md @@ -60,7 +60,7 @@ So a Session-scoped service is not "L1" — e.g. `session` is Session-scoped but |---|---|---| | L0 | base infrastructure | `_base`, `errors`, `llmProtocol` | | L1 | bridges & low-level capabilities | `log`, `telemetry`, `event`, `environment`, `bootstrap`, `storage` | -| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspaceRegistry` | +| L2 | data & cross-cutting capabilities | `records`, `wireRecord`, `config`, `provider`, `auth`, `workspace` | | L3 | registries & capabilities | `tool`, `toolRegistry`, `permission*`, `flag`, `skill`, `plugin` | | L4 | agent behaviour | `turn`, `loop`, `prompt`, `profile`, `contextMemory`, `goal`, `plan`, `swarm` | | L5 | async lifecycle | `background`, `mcp`, `cron`, `agentTool` | diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index 82b9188f1d..f7976b7c9e 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -67,7 +67,7 @@ Self-check: "would a released v1 client get a byte-identical envelope from `pack Resolve the v2 Service that will back the route. Two cases: -**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceRegistry`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. +**Case A — the v2 native Service already matches the v1 contract.** Use it directly. Most data/command Services (`IConfigService`, `IWorkspaceService`, `IApprovalService`, `IQuestionService`, `IFileStore`, …) land here: the route is a thin adapter that resolves the scope, calls the method, and wraps the result. Examples: `routes/config.ts`, `routes/messages.ts`, `routes/questions.ts`, `routes/files.ts`. **Case B — the v1 contract needs behavior that would distort the v2 domain.** Introduce a **`*LegacyService`** — an L7 edge adapter that implements the v1 contract **on top of** the v2 native Service, leaving the native Service untouched. The v2 native Service keeps serving `/api/v2`; the LegacyService serves `/api/v1`. diff --git a/.changeset/slim-reset-baseline.md b/.changeset/slim-reset-baseline.md new file mode 100644 index 0000000000..40fdeb059d --- /dev/null +++ b/.changeset/slim-reset-baseline.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Stop embedding historical turns in the transcript WS baseline reset; it now carries only global state and the stream watermark, and clients page history through the REST transcript API. diff --git a/AGENTS.md b/AGENTS.md index a2b97d7f2a..37b16bd11b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,15 +17,15 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `apps/kimi-code`: the CLI / TUI application. It consumes core capabilities through `@moonshot-ai/kimi-code-sdk` and must not depend directly on `@moonshot-ai/agent-core`. When writing or modifying its terminal UI, use the `write-tui` skill (`.agents/skills/write-tui/SKILL.md`). - `apps/kimi-web`: the browser web UI, a peer to the TUI. Vue 3 + Vite + vue-i18n; talks to the server over REST + WebSocket under `/api/v1`. It must not depend on `@moonshot-ai/agent-core` (wire types are re-implemented locally). Debug against the two engines via the root `pnpm dev:v1` / `pnpm dev:v2` backend scripts — the dev Sidebar shows the active backend and switches it at runtime. See `apps/kimi-web/AGENTS.md`. - `apps/vis`, `apps/vis/server`, `apps/vis/web`: visual debugging tools for sessions and replays. -- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards, "load earlier" pages with `before_turn`), while `/api/v1/ws` is a delta-only channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored). Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). +- `apps/kimi-inspect`: web inspector for the kap-server `/api/v1/debug` RPC surface — workspace/session browser, per-session chat, and Service panels (data + trigger buttons) for the Session and Agent scopes. A left icon rail (`src/components/NavRail.tsx`) switches top-level views: the Chat workspace, the Model Catalog (`src/components/ModelCatalogView.tsx` — every Provider with its Models and the default marker, via `IModelCatalog` / `IModelService` channel proxies), and App Services (`src/components/AppServicesView.tsx` — the app-scope Service reflection, full width; session/agent scopes stay in the Chat view's right `Inspector`). Expanding a Model opens the model inspector inside that view: provider/model config layers plus the resolved runtime view with per-value provenance (config / override / builtin / env / synthesized), served on demand by `IModelCatalog.inspect` — the same resolution pass the runtime's `get` serves, traced via `ResolutionTraceCollector` and assembled by `kosong/model/inspection.ts`. Built on its own old-klient-style channel layer (`src/channel/`: the VS Code `ProxyChannel` model — service-bound `IChannel`, HTTP `ProxyChannel` for calls routed to `/api/v1/debug`), typed by `agent-core-v2` Service interfaces; `GET /api/v1/debug/channels` loads the whole wire protocol 1:1 (every scoped Service, no whitelist). There is no Service-event push channel: panels fetch/refresh on demand (`Sidebar` polls react-query on a 15 s interval), and a connection failure shows a blocking "Debug surface unavailable" screen instead of falling back anywhere. The Vite dev server proxies `/api` to a running kap-server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`) and exposes `GET /__inspect/servers` (`vite/serverDiscovery.ts`), which scans the local kap-server instance registry (`~/.kimi-code/server/instances` + legacy `lock`) and the home token so the app can zero-config auto-connect and switch servers from the header dropdown at runtime. The per-session chat (`src/components/ChatView.tsx`) renders turn-granularly from the **transcript** surface instead of context memory: full state is read from `GET /api/v1/sessions/{id}/transcript` (initial load = newest page, refreshes re-read from the tail backwards), older history auto-pages with `before_turn` via an IntersectionObserver sentinel at the top of the scroll view, and each timeline item is wrapped in `content-visibility: auto` + `contain-intrinsic-size` so the browser virtualizes off-screen rendering natively (no windowing library); `/api/v1/ws` is a delta channel (`transcript.ops`, grade `delta`; `transcript.reset` is ignored by the store, surfaced only to the audit recorder via the optional `onReset` handler). The channel tracks the op-batch watermark: `client_hello` carries `transcript_since`, a seq gap / reconnect / `resync_required` / append gap triggers a point-to-point catch-up (`fetchTranscriptOps` → `GET .../transcript/ops?since_seq=`), and any legacy/incomplete answer falls back to the full REST refresh. Convergence reuses `@moonshot-ai/transcript`'s L2 reducer (`src/transcript/`: REST/WS clients + store; the data model and reducer come from the package, nothing is re-implemented locally). The Transcript audit panel (`src/components/audit/`, always docked right of the chat) replays how the visible store was built: an `AuditTrail` (`src/audit/`) records every step — each REST page (request + replace/prepend), every WS frame (`transcript.ops` live/buffered/flushed/catchup, `transcript.reset`), loss signals, and prompt/cancel actions — with the resulting immutable `AgentState` per entry; the panel offers a draggable timeline plus a Diff tab (structural diff vs the previous entry: added/modified/removed colored, long strings tail-truncated, all fields kept), a full State view, and the raw Event payload. - `packages/agent-core`: the unified agent engine, including Agent, Session, profile, skills, tools, plan, permission, background, records, the in-process DI service layer (`src/services/`), and other core capabilities. - `packages/node-sdk`: the public TypeScript SDK and harness. - `packages/kosong`: the LLM / provider abstraction layer. - `packages/kaos`: the execution environment and file/process abstractions. - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. -- `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript wire types; consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). -- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. +- `packages/transcript`: the isomorphic transcript rendering data layer — agent-granular L1 store, idempotent L2 operations, `off/turn/block/delta` L3 subscription granularity, framework-free L4 view registry, and turn-cursor pagination. Pure TypeScript (browser-safe, no engine imports) and the sole owner of all transcript wire types; consumed by `packages/kap-server` (engine events → transcript, REST + WS surface; live stores backfill history from the persisted per-agent wire records — main on first attach, any agent on demand, cold sessions rebuild any agent — with 0-based turn ordinals matching the engine's). It also owns the op-batch sequencing contract (`transcriptSeqSchema` in `wire/schema.ts`): a per-(session, agent) monotonic batch `seq` on `transcript.ops` / `transcript.reset` / the REST transcript response, the `transcript_since` subscription cursor, and the `GET .../transcript/ops` catch-up response shape — every field optional so pre-seq peers fall back to loss-signal-driven refreshes. +- `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` + `/api/v1/ws`); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. The RPC surface is `/api/v1/debug/*` — a reflection dispatcher over the ENTIRE scoped DI registry (every Service callable, no whitelist; `src/transport/registerDebugRoutes.ts` + `serviceDispatcherRoutes.ts`), mounted only with `--debug-endpoints` on a loopback bind and gated by the global bearer auth; repo dev scripts pass the flag. Its transcript surface implements the op-batch sequencing contract: `TranscriptService.dispatchOps` assigns every dispatched batch a per-agent consecutive `seq` and retains it in a bounded in-memory journal (`TRANSCRIPT_OPS_JOURNAL_CAPACITY`, dies with the live store); WS `transcript.ops`/`transcript.reset` payloads carry the seq/watermark, a `transcript_since` subscription cursor replays journaled batches instead of a baseline reset when the journal covers it, and `GET /sessions/{id}/transcript/ops?since_seq=` serves point-to-point catch-up (`complete: false` = journal can't cover or session cold → caller falls back to a full refresh). The baseline `transcript.reset` itself is items-empty (`TRANSCRIPT_RESET_TAIL_TURNS = 0`): it carries only global state + the watermark + `has_more_older`, because history always pages in over REST. - `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/ipc|memory`); both return the same `Klient`. The package also hosts the e2e suites: the legacy `/api/v1` live suites (`test/e2e/legacy/`) and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. - `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. diff --git a/apps/kimi-inspect/src/audit/audit.test.ts b/apps/kimi-inspect/src/audit/audit.test.ts new file mode 100644 index 0000000000..24fb4fe352 --- /dev/null +++ b/apps/kimi-inspect/src/audit/audit.test.ts @@ -0,0 +1,219 @@ +/** + * Audit-layer tests: the trail recorder, the structural diff, serialization, + * and tail-preserving truncation used by the chat view's audit panel. + */ + +import { describe, expect, it } from 'vitest'; + +import { + EMPTY_AGENT_STATE, + type AgentState, + type TranscriptTurn, +} from '@moonshot-ai/transcript'; + +import { diffValue, type DiffNode } from './diff'; +import { serializeState } from './serialize'; +import { AuditTrail, AUDIT_TRAIL_MAX_ENTRIES } from './trail'; +import { tailTrunc } from './truncate'; + +function turnItem(n: number): TranscriptTurn { + return { kind: 'turn', turnId: `t${n}`, ordinal: n, state: 'completed', origin: { kind: 'user' }, steps: [] }; +} + +function stateWith(items: readonly TranscriptTurn[]): AgentState { + return { ...EMPTY_AGENT_STATE, items }; +} + +// ---------------------------------------------------------------- diff + +describe('diffValue', () => { + it('collapses reference-equal subtrees to unchanged without children', () => { + const shared = { a: 1, b: { c: 'x' } }; + const node = diffValue({ v: shared }, { v: shared }); + expect(node.status).toBe('unchanged'); + expect(node.children?.get('v')?.children).toBeUndefined(); + }); + + it('marks added, removed, and modified object keys', () => { + const node = diffValue({ keep: 1, gone: 'x', changed: 'a' }, { keep: 1, fresh: true, changed: 'b' }); + expect(node.status).toBe('modified'); + expect(node.children?.get('keep')?.status).toBe('unchanged'); + expect(node.children?.get('fresh')?.status).toBe('added'); + expect(node.children?.get('gone')).toMatchObject({ status: 'removed', prev: 'x' }); + expect(node.children?.get('changed')).toMatchObject({ status: 'modified', prev: 'a', value: 'b' }); + }); + + it('matches entity arrays by id instead of index', () => { + const prev = [turnItem(1), turnItem(2)]; + const next = [turnItem(1), { ...turnItem(2), state: 'running' as const }, turnItem(3)]; + const node = diffValue(prev, next); + expect(node.children?.get('t1')?.status).toBe('unchanged'); + expect(node.children?.get('t2')?.status).toBe('modified'); + expect(node.children?.get('t2')?.children?.get('state')).toMatchObject({ + status: 'modified', + prev: 'completed', + value: 'running', + }); + expect(node.children?.get('t3')?.status).toBe('added'); + }); + + it('keys steps by stepId (not their shared turnId) so siblings never collide', () => { + const step = (id: string, state: 'running' | 'completed') => ({ + kind: 'step' as const, + stepId: id, + turnId: 't1', + ordinal: 1, + state, + frames: [], + }); + const node = diffValue( + [step('t1.1', 'completed'), step('t1.2', 'completed')], + [step('t1.1', 'completed'), step('t1.2', 'running')], + ); + expect([...node.children?.keys() ?? []]).toEqual(['t1.1', 't1.2']); + expect(node.children?.get('t1.1')?.status).toBe('unchanged'); + expect(node.children?.get('t1.2')?.status).toBe('modified'); + }); + + it('marks removed array elements by id', () => { + const node = diffValue([turnItem(1), turnItem(2)], [turnItem(2)]); + expect(node.children?.get('t1')).toMatchObject({ status: 'removed' }); + expect(node.children?.get('t2')?.status).toBe('unchanged'); + }); + + it('marks whole-subtree adds/removes without descending', () => { + const added = diffValue(undefined, { nested: { deep: 1 } }); + expect(added.status).toBe('added'); + expect(added.children).toBeUndefined(); + const removed = diffValue({ nested: 1 }, undefined); + expect(removed.status).toBe('removed'); + expect(removed.children).toBeUndefined(); + }); + + it('treats type changes as leaf modifications', () => { + expect(diffValue('1', 1).status).toBe('modified'); + expect(diffValue(null, {}).status).toBe('modified'); + expect(diffValue([1], { 0: 1 }).status).toBe('modified'); + }); + + it('diffs two serialized states with meta changes visible (goal/plan fields)', () => { + const prev = serializeState(stateWith([turnItem(1)])); + const nextState: AgentState = { + ...stateWith([turnItem(1)]), + meta: { + goal: { objective: 'ship it', status: 'active' }, + modes: { plan: { reviewPath: '/tmp/plan.md' } }, + }, + }; + const node: DiffNode = diffValue(prev, serializeState(nextState)); + expect(node.children?.get('items')?.status).toBe('unchanged'); + const meta = node.children?.get('meta'); + expect(meta?.status).toBe('modified'); + expect(meta?.children?.get('goal')?.status).toBe('added'); + // Whole-subtree add: `modes` was absent before, so the block (plan + // included) is marked added without descending into children. + expect(meta?.children?.get('modes')?.status).toBe('added'); + expect(meta?.children?.get('modes')?.children).toBeUndefined(); + }); +}); + +// ---------------------------------------------------------------- serialize + +describe('serializeState', () => { + it('turns maps into sorted plain objects and sets into arrays', () => { + const state: AgentState = { + ...EMPTY_AGENT_STATE, + tasks: new Map([ + ['b-task', { taskId: 'b-task', kind: 'shell', state: 'running', detached: false, outputTail: '' }], + ['a-task', { taskId: 'a-task', kind: 'tool', state: 'completed', detached: false, outputTail: '' }], + ]), + pendingInteractions: new Set(['z', 'a']), + }; + const out = serializeState(state); + expect(Object.keys(out.tasks as Record)).toEqual(['a-task', 'b-task']); + expect(out.pendingInteractions).toEqual(['a', 'z']); + expect(out.hasMoreOlder).toBe(false); + }); +}); + +// ---------------------------------------------------------------- truncate + +describe('tailTrunc', () => { + it('returns short strings unchanged', () => { + expect(tailTrunc('hello')).toBe('hello'); + expect(tailTrunc('x'.repeat(500))).toBe('x'.repeat(500)); + }); + + it('keeps the tail of long strings and reports the total length', () => { + const value = 'head-padding'.repeat(100) + 'THE-TAIL'; + const out = tailTrunc(value, 50); + expect(out).toContain(`${value.length} chars total`); + expect(out.endsWith('THE-TAIL')).toBe(true); + expect(out).not.toContain('head-padding'.repeat(10)); + }); +}); + +// ---------------------------------------------------------------- trail + +describe('AuditTrail', () => { + const page = { + items: [turnItem(1)], + hasMoreOlder: false, + tasks: [], + interactions: [], + attachments: [], + todos: [], + meta: {}, + pendingInteractions: [], + }; + + it('records entries with increasing indices, timestamps, and state references', () => { + const trail = new AuditTrail(); + const s1 = stateWith([turnItem(1)]); + const s2 = stateWith([turnItem(1), turnItem(2)]); + trail.recordRest({ pageSize: 30 }, 'replace', page, s1); + trail.recordOps([{ op: 'turn.upsert', turn: turnItem(2) }], 'live', '2026-01-01T00:00:00Z', s2); + trail.recordEvent('prompt', 'hello', s2); + trail.recordReset( + { items: [], tasks: [], interactions: [], attachments: [], todos: [], meta: {} }, + false, + undefined, + s2, + ); + + const entries = trail.getEntries(); + expect(entries.map((entry) => entry.kind)).toEqual(['rest', 'ops', 'event', 'reset']); + expect(entries.map((entry) => entry.index)).toEqual([0, 1, 2, 3]); + expect(entries[0]!.state).toBe(s1); + expect(entries[1]!.state).toBe(s2); + expect(entries[1]).toMatchObject({ delivery: 'live', envelopeAt: '2026-01-01T00:00:00Z' }); + expect(entries[2]).toMatchObject({ event: 'prompt', detail: 'hello' }); + expect(entries.every((entry) => typeof entry.at === 'string' && entry.at.length > 0)).toBe(true); + expect(entries.every((entry) => entry.summary.length > 0)).toBe(true); + }); + + it('notifies subscribers on each record', () => { + const trail = new AuditTrail(); + let notified = 0; + const unsubscribe = trail.subscribe(() => { + notified += 1; + }); + trail.recordEvent('cancel', undefined, EMPTY_AGENT_STATE); + trail.recordEvent('gap', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + unsubscribe(); + trail.recordEvent('resync', undefined, EMPTY_AGENT_STATE); + expect(notified).toBe(2); + }); + + it('drops the oldest entries beyond the cap while indices keep increasing', () => { + const trail = new AuditTrail(); + for (let i = 0; i < AUDIT_TRAIL_MAX_ENTRIES + 10; i += 1) { + trail.recordEvent('prompt', `p${i}`, EMPTY_AGENT_STATE); + } + const entries = trail.getEntries(); + expect(entries).toHaveLength(AUDIT_TRAIL_MAX_ENTRIES); + expect(entries[0]!.index).toBe(10); + expect(entries.at(-1)!.index).toBe(AUDIT_TRAIL_MAX_ENTRIES + 9); + }); +}); diff --git a/apps/kimi-inspect/src/audit/diff.ts b/apps/kimi-inspect/src/audit/diff.ts new file mode 100644 index 0000000000..613d7ebe6e --- /dev/null +++ b/apps/kimi-inspect/src/audit/diff.ts @@ -0,0 +1,120 @@ +/** + * Structural diff over serialized `AgentState` values (see `serialize.ts`). + * + * The audit panel diffs two adjacent, immutable states. Because the store + * is copy-on-write, untouched subtrees share references — the reference + * equality fast path below collapses them to `unchanged` without walking. + * + * Arrays of transcript entities are matched by their id field (turnId, + * stepId, frameId, …) rather than by index, so an upsert in the middle of + * the timeline does not turn into a cascade of spurious modifications. + */ + +export type DiffStatus = 'unchanged' | 'added' | 'removed' | 'modified'; + +export interface DiffNode { + readonly status: DiffStatus; + /** Current value (`undefined` when removed). */ + readonly value: unknown; + /** Previous value (`undefined` when added). */ + readonly prev: unknown; + /** + * Object/array children — key is the object key, the entity id, or + * `#` for plain arrays. Absent on leaves and on whole-subtree + * added/removed nodes (the renderer colors the subtree as one block). + */ + readonly children?: ReadonlyMap; +} + +/** + * Id fields checked in priority order — MOST SPECIFIC FIRST. A step carries + * both `turnId` and `stepId`, and a frame can carry `taskId` alongside its + * `frameId`; matching the wrong one mislabels the node and, worse, collides + * siblings in the children map (two steps of one turn both keyed `t1`). + */ +const ID_FIELDS = [ + 'frameId', + 'stepId', + 'interactionId', + 'attachmentId', + 'todoId', + 'markerId', + 'refId', + 'turnId', + 'taskId', +] as const; + +function elementId(element: unknown): string | undefined { + if (typeof element !== 'object' || element === null) return undefined; + for (const field of ID_FIELDS) { + const value = (element as Record)[field]; + if (typeof value === 'string') return value; + } + return undefined; +} + +/** Public for the audit UI: same id-priority keying used to match array elements. */ +export { elementId }; + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function containerStatus(children: ReadonlyMap): DiffStatus { + for (const child of children.values()) { + if (child.status !== 'unchanged') return 'modified'; + } + return 'unchanged'; +} + +function diffObjects(prev: Record, next: Record): DiffNode { + const children = new Map(); + for (const key of Object.keys(next)) { + children.set(key, diffValue(prev[key], next[key])); + } + for (const key of Object.keys(prev)) { + if (!(key in next)) { + children.set(key, { status: 'removed', value: undefined, prev: prev[key] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +function diffArrays(prev: readonly unknown[], next: readonly unknown[]): DiffNode { + const children = new Map(); + const keyed = + prev.every((el) => elementId(el) !== undefined) && + next.every((el) => elementId(el) !== undefined); + + if (keyed) { + const prevById = new Map(); + for (const el of prev) prevById.set(elementId(el) as string, el); + const nextIds = new Set(); + for (const el of next) { + const id = elementId(el) as string; + nextIds.add(id); + children.set(id, diffValue(prevById.get(id), el)); + } + for (const el of prev) { + const id = elementId(el) as string; + if (!nextIds.has(id)) children.set(id, { status: 'removed', value: undefined, prev: el }); + } + } else { + for (let i = 0; i < next.length; i += 1) { + children.set(`#${i}`, diffValue(prev[i], next[i])); + } + for (let i = next.length; i < prev.length; i += 1) { + children.set(`#${i}`, { status: 'removed', value: undefined, prev: prev[i] }); + } + } + return { status: containerStatus(children), value: next, prev, children }; +} + +export function diffValue(prev: unknown, next: unknown): DiffNode { + if (prev === next) return { status: 'unchanged', value: next, prev }; + if (prev === undefined) return { status: 'added', value: next, prev: undefined }; + if (next === undefined) return { status: 'removed', value: undefined, prev }; + if (isPlainObject(prev) && isPlainObject(next)) return diffObjects(prev, next); + if (Array.isArray(prev) && Array.isArray(next)) return diffArrays(prev, next); + return { status: 'modified', value: next, prev }; +} diff --git a/apps/kimi-inspect/src/audit/serialize.ts b/apps/kimi-inspect/src/audit/serialize.ts new file mode 100644 index 0000000000..ddcbecea3c --- /dev/null +++ b/apps/kimi-inspect/src/audit/serialize.ts @@ -0,0 +1,48 @@ +/** + * Serialize an `AgentState` into a plain, JSON-shaped object for the audit + * panel's state tree and structural diff. Maps become key-sorted plain + * objects (stable display order), Sets become sorted arrays; everything + * else is passed through by reference (state is immutable, so sharing is + * safe and keeps the reference-equality fast path in `diffValue` useful). + */ + +import type { + AgentState, + TranscriptAttachment, + TranscriptInteraction, + TranscriptItem, + TranscriptMeta, + TranscriptTask, + TranscriptTodo, +} from '@moonshot-ai/transcript'; + +/** Plain-object view of an `AgentState` (Maps/Sets unwrapped). */ +export interface SerializedAgentState { + readonly items: readonly TranscriptItem[]; + readonly tasks: Record; + readonly interactions: Record; + readonly attachments: Record; + readonly todos: Record; + readonly meta: TranscriptMeta; + readonly pendingInteractions: readonly string[]; + readonly hasMoreOlder: boolean; +} + +function mapToSortedObject(map: ReadonlyMap): Record { + const out: Record = {}; + for (const key of [...map.keys()].sort()) out[key] = map.get(key) as V; + return out; +} + +export function serializeState(state: AgentState): SerializedAgentState { + return { + items: state.items, + tasks: mapToSortedObject(state.tasks), + interactions: mapToSortedObject(state.interactions), + attachments: mapToSortedObject(state.attachments), + todos: mapToSortedObject(state.todos), + meta: state.meta, + pendingInteractions: [...state.pendingInteractions].sort(), + hasMoreOlder: state.hasMoreOlder, + }; +} diff --git a/apps/kimi-inspect/src/audit/trail.ts b/apps/kimi-inspect/src/audit/trail.ts new file mode 100644 index 0000000000..abbe260031 --- /dev/null +++ b/apps/kimi-inspect/src/audit/trail.ts @@ -0,0 +1,171 @@ +/** + * Audit trail for the chat view's transcript channel. + * + * A pure observer: the chat pipeline (REST loads, WS frames, user actions) + * calls the `record*` methods AFTER applying each step to the real + * `TranscriptChatStore`, passing the resulting immutable `AgentState` + * reference. Replaying the trail is therefore free — every entry already + * holds the exact state the store had at that point, ready for the + * timeline slider and the structural diff. + */ + +import type { + AgentState, + AgentTranscriptSnapshot, + TranscriptOperation, +} from '@moonshot-ai/transcript'; + +import type { TranscriptPage } from '../transcript/api'; + +export const AUDIT_TRAIL_MAX_ENTRIES = 5000; + +interface AuditEntryBase { + /** Position in the trail (stable even when old entries are dropped). */ + readonly index: number; + /** Local record time (ISO). */ + readonly at: string; + /** Store state right after this entry was applied (immutable reference). */ + readonly state: AgentState; + /** One-line summary for the timeline list. */ + readonly summary: string; +} + +export interface RestAuditEntry extends AuditEntryBase { + readonly kind: 'rest'; + readonly request: { readonly beforeTurn?: string | undefined; readonly pageSize: number }; + readonly appliedAs: 'replace' | 'prepend'; + readonly page: TranscriptPage; +} + +export interface OpsAuditEntry extends AuditEntryBase { + readonly kind: 'ops'; + /** Envelope timestamp (server send time) when present. */ + readonly envelopeAt?: string | undefined; + readonly ops: readonly TranscriptOperation[]; + /** live = applied immediately; buffered = held during a REST refresh; flushed = replayed after one; catchup = fetched via the ops catch-up endpoint after a seq gap. */ + readonly delivery: 'live' | 'buffered' | 'flushed' | 'catchup'; +} + +export interface ResetAuditEntry extends AuditEntryBase { + readonly kind: 'reset'; + readonly envelopeAt?: string | undefined; + readonly snapshot: AgentTranscriptSnapshot; + readonly hasMoreOlder: boolean; +} + +export interface EventAuditEntry extends AuditEntryBase { + readonly kind: 'event'; + readonly event: 'ack-refresh' | 'resync' | 'gap' | 'prompt' | 'cancel'; + readonly detail?: string | undefined; +} + +export type AuditEntry = RestAuditEntry | OpsAuditEntry | ResetAuditEntry | EventAuditEntry; + +type DistributiveOmit = T extends unknown ? Omit : never; + +/** Entry payload accepted by `push` (index/at are filled in there). */ +type AuditEntryInput = DistributiveOmit; + +function summarizeOps(ops: readonly TranscriptOperation[]): string { + const counts = new Map(); + for (const op of ops) counts.set(op.op, (counts.get(op.op) ?? 0) + 1); + return [...counts.entries()].map(([name, n]) => (n > 1 ? `${name}×${n}` : name)).join(', '); +} + +export class AuditTrail { + private entryList: AuditEntry[] = []; + private nextIndex = 0; + private readonly listeners = new Set<() => void>(); + + /** `useSyncExternalStore`-compatible subscribe. */ + subscribe = (listener: () => void): (() => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getEntries(): readonly AuditEntry[] { + return this.entryList; + } + + recordRest( + request: RestAuditEntry['request'], + appliedAs: RestAuditEntry['appliedAs'], + page: TranscriptPage, + state: AgentState, + ): void { + const cursor = request.beforeTurn !== undefined ? `?before_turn=${request.beforeTurn}` : ''; + this.push({ + kind: 'rest', + request, + appliedAs, + page, + state, + summary: `GET transcript${cursor} → ${page.items.length} items (${appliedAs})`, + }); + } + + recordOps( + ops: readonly TranscriptOperation[], + delivery: OpsAuditEntry['delivery'], + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'ops', + ops, + delivery, + envelopeAt, + state, + summary: `${ops.length} ops (${summarizeOps(ops)}) [${delivery}]`, + }); + } + + recordReset( + snapshot: AgentTranscriptSnapshot, + hasMoreOlder: boolean, + envelopeAt: string | undefined, + state: AgentState, + ): void { + this.push({ + kind: 'reset', + snapshot, + hasMoreOlder, + envelopeAt, + state, + summary: `reset snapshot (${snapshot.items.length} items) — ignored by chat store`, + }); + } + + recordEvent(event: EventAuditEntry['event'], detail: string | undefined, state: AgentState): void { + const label = + event === 'ack-refresh' + ? 'subscribe ack → REST refresh' + : event === 'resync' + ? 'resync_required → REST refresh' + : event === 'gap' + ? 'append gap → REST refresh' + : event === 'prompt' + ? 'prompt sent' + : 'cancel sent'; + this.push({ + kind: 'event', + event, + detail, + state, + summary: detail !== undefined && detail !== '' ? `${label}: ${detail}` : label, + }); + } + + private push(entry: AuditEntryInput): void { + const full = { ...entry, index: this.nextIndex, at: new Date().toISOString() } as AuditEntry; + this.nextIndex += 1; + const kept = + this.entryList.length >= AUDIT_TRAIL_MAX_ENTRIES + ? this.entryList.slice(this.entryList.length - AUDIT_TRAIL_MAX_ENTRIES + 1) + : this.entryList; + this.entryList = [...kept, full]; + for (const listener of this.listeners) listener(); + } +} diff --git a/apps/kimi-inspect/src/audit/truncate.ts b/apps/kimi-inspect/src/audit/truncate.ts new file mode 100644 index 0000000000..69a7c137c7 --- /dev/null +++ b/apps/kimi-inspect/src/audit/truncate.ts @@ -0,0 +1,14 @@ +/** + * Tail-preserving string truncation for the audit panel: long values are + * rendered with their total length plus the LAST `keep` characters (the + * tail is where streaming text, tool output, and prompts carry the newest + * information). Rendering-only — the underlying state is never truncated, + * and no field is ever dropped. + */ + +export const TRUNCATE_KEEP = 500; + +export function tailTrunc(value: string, keep: number = TRUNCATE_KEEP): string { + if (value.length <= keep) return value; + return `… [${value.length} chars total, showing last ${keep}]\n${value.slice(-keep)}`; +} diff --git a/apps/kimi-inspect/src/components/ChatView.tsx b/apps/kimi-inspect/src/components/ChatView.tsx index 2f3b3d136b..91ee88b7f6 100644 --- a/apps/kimi-inspect/src/components/ChatView.tsx +++ b/apps/kimi-inspect/src/components/ChatView.tsx @@ -20,7 +20,9 @@ */ import { + createContext, useCallback, + useContext, useEffect, useLayoutEffect, useRef, @@ -29,11 +31,16 @@ import { } from 'react'; import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; +import { + ISessionQuestionService, + type QuestionItem, + type QuestionRequest, +} from '@moonshot-ai/agent-core-v2/session/question/question'; import { EMPTY_AGENT_STATE, itemId, type AgentState, - type InteractionFrame, type NoticeFrame, type ToolCallFrame, type TranscriptAttachment, @@ -51,7 +58,8 @@ import { } from '@moonshot-ai/transcript'; import { useConnection } from '../connection'; -import { fetchTranscriptPage, TRANSCRIPT_PAGE_SIZE } from '../transcript/api'; +import { AuditTrail } from '../audit/trail'; +import { fetchTranscriptOps, fetchTranscriptPage, TRANSCRIPT_PAGE_SIZE } from '../transcript/api'; import { createCoalescedRunner, oldestTurnId, @@ -60,13 +68,19 @@ import { } from '../transcript/store'; import { TranscriptWs } from '../transcript/ws'; import { ActionButton, Badge, ErrorLine, JsonView, relTime } from '../ui'; +import { AuditPanel } from './audit/AuditPanel'; const noopSubscribe = () => () => {}; +/** Active session id for deeply nested interaction views (approve/answer buttons). */ +const SessionContext = createContext(''); + interface TranscriptChannel { /** Null until the effect has created the store (pre-ready / no session). */ readonly store: TranscriptChatStore | null; readonly state: AgentState; + /** Records every step that built the store (audit panel data source). */ + readonly trail: AuditTrail | null; /** True once the initial REST page load succeeded. */ readonly loaded: boolean; /** Set when the initial/refresh load failed (e.g. server without transcript). */ @@ -85,64 +99,157 @@ function useTranscriptChannel( ): TranscriptChannel { const { baseUrl, config } = useConnection(); const token = config.token.trim(); - const [channel, setChannel] = useState<{ store: TranscriptChatStore } | null>(null); + const [channel, setChannel] = useState<{ store: TranscriptChatStore; trail: AuditTrail } | null>( + null, + ); const [loaded, setLoaded] = useState(false); const [loadError, setLoadError] = useState(null); useEffect(() => { if (!ready || sessionId === null) return; const store = new TranscriptChatStore(); + const trail = new AuditTrail(); const authToken = token === '' ? undefined : token; let disposed = false; - /** While a REST refresh is in flight, WS ops are buffered, then flushed. */ + /** While a REST reload / catch-up is in flight, WS ops are buffered, then flushed. */ let fetching = true; let buffer: TranscriptOperation[] = []; + /** Max batch seq seen while buffering (folded into the watermark on flush). */ + let bufferedSeq: number | undefined; + /** + * Op-batch watermark: the store is known to include every batch with + * seq <= lastSeq. Sourced from REST page watermarks and applied batch + * seqs; `undefined` until a sequenced server provides one (legacy + * servers never do — every recovery then falls back to full refreshes). + */ + let lastSeq: number | undefined; + /** Cursor of the in-flight recover fetch, paired with `onPageApplied`. */ + let recoverBefore: string | undefined; + /** True once the initial page load succeeded (gates reset-driven catch-up). */ + let seeded = false; - /** Full-state (re)load: newest page first, then backwards over `before_turn`. */ - const refresh = createCoalescedRunner(async (): Promise => { - fetching = true; + const noteSeq = (seq: number | undefined): void => { + if (seq === undefined) return; + lastSeq = lastSeq === undefined ? seq : Math.max(lastSeq, seq); + }; + + const flushBuffer = (): void => { + fetching = false; + if (buffer.length > 0) { + const flushed = buffer; + store.applyOps(flushed); + trail.recordOps(flushed, 'flushed', undefined, store.getState()); + noteSeq(bufferedSeq); + } buffer = []; + bufferedSeq = undefined; + }; + + /** Page (re)load body shared by the full refresh and the catch-up fallback. */ + const reloadPages = async (): Promise => { // The window's oldest turn is the re-cover anchor: after a refresh the // server window may have shifted, and only re-loading up to THIS turn // preserves the previously loaded history. const prevOldest = oldestTurnId(store.getState().items); if (prevOldest !== undefined) captureAnchor(); + const newest = await fetchTranscriptPage({ + baseUrl, + token: authToken, + sessionId, + agentId, + pageSize: TRANSCRIPT_PAGE_SIZE, + }); + if (disposed) return; + store.applyPage(newest, { replace: true }); + trail.recordRest({ pageSize: TRANSCRIPT_PAGE_SIZE }, 'replace', newest, store.getState()); + lastSeq = newest.seq; + // Re-cover the previously loaded window for refreshes (a no-op on the + // initial load, where there is no previous oldest turn). + await recoverLoadedWindow( + store, + prevOldest, + (beforeTurn) => { + recoverBefore = beforeTurn; + return fetchTranscriptPage({ + baseUrl, + token: authToken, + sessionId, + agentId, + beforeTurn, + pageSize: TRANSCRIPT_PAGE_SIZE, + }); + }, + () => disposed, + (page) => { + trail.recordRest( + { beforeTurn: recoverBefore, pageSize: TRANSCRIPT_PAGE_SIZE }, + 'prepend', + page, + store.getState(), + ); + }, + ); + if (!disposed) { + seeded = true; + setLoaded(true); + setLoadError(null); + } + }; + + /** Full-state (re)load: the legacy recovery path and the initial load. */ + const refresh = createCoalescedRunner(async (): Promise => { + fetching = true; + buffer = []; + bufferedSeq = undefined; try { - const newest = await fetchTranscriptPage({ + await reloadPages(); + } catch (error) { + if (!disposed) setLoadError(error); + } finally { + flushBuffer(); + } + }); + + /** + * Targeted catch-up: fetch exactly the op batches after our watermark + * (`GET .../transcript/ops?since_seq=`). Falls back to a full page + * reload on a legacy server (no seq / endpoint missing), a journal that + * no longer covers the gap (`complete: false`), or a fetch failure. + */ + const catchUp = createCoalescedRunner(async (): Promise => { + if (lastSeq === undefined) { + refresh(); + return; + } + fetching = true; + buffer = []; + bufferedSeq = undefined; + try { + const res = await fetchTranscriptOps({ baseUrl, token: authToken, sessionId, agentId, - pageSize: TRANSCRIPT_PAGE_SIZE, + sinceSeq: lastSeq, }); if (disposed) return; - store.applyPage(newest, { replace: true }); - // Re-cover the previously loaded window for refreshes (a no-op on the - // initial load, where there is no previous oldest turn). - await recoverLoadedWindow( - store, - prevOldest, - (beforeTurn) => - fetchTranscriptPage({ - baseUrl, - token: authToken, - sessionId, - agentId, - beforeTurn, - pageSize: TRANSCRIPT_PAGE_SIZE, - }), - () => disposed, - ); - if (!disposed) { - setLoaded(true); - setLoadError(null); + if (!res.complete) { + await reloadPages(); + } else { + for (const batch of res.batches) { + store.applyOps(batch.ops); + trail.recordOps(batch.ops, 'catchup', undefined, store.getState()); + } + noteSeq(res.latestSeq); + } + } catch { + try { + await reloadPages(); + } catch (error) { + if (!disposed) setLoadError(error); } - } catch (error) { - if (!disposed) setLoadError(error); } finally { - fetching = false; - if (buffer.length > 0) store.applyOps(buffer); - buffer = []; + flushBuffer(); } }); @@ -151,18 +258,53 @@ function useTranscriptChannel( token: authToken, sessionId, agentId, + getSince: () => lastSeq, handlers: { - onOps: (aid, ops) => { + onOps: (aid, ops, meta) => { if (aid !== agentId) return; - if (fetching) buffer.push(...ops); - else store.applyOps(ops); + if (fetching) { + buffer.push(...ops); + if (meta?.seq !== undefined) { + bufferedSeq = Math.max(bufferedSeq ?? 0, meta.seq); + } + trail.recordOps(ops, 'buffered', meta?.at, store.getState()); + return; + } + // Seq gap: the store is behind by at least one batch. Catch up + // point-to-point instead of applying on a stale base (appends are + // offset-placed and would surface a gap anyway). + if (meta?.seq !== undefined && lastSeq !== undefined && meta.seq > lastSeq + 1) { + catchUp(); + return; + } + store.applyOps(ops); + trail.recordOps(ops, 'live', meta?.at, store.getState()); + noteSeq(meta?.seq); + }, + onReset: (_aid, snapshot, hasMoreOlder, meta) => { + trail.recordReset(snapshot, hasMoreOlder, meta?.at, store.getState()); + // Sequenced mode only: a reset after seeding means the server could + // not replay from our `transcript_since` cursor (journal truncated) + // — catch up, which itself falls back to a full reload when the seq + // window is gone. On legacy servers (no watermark) resets are + // routine per-subscribe noise and stay ignored, as before. + if (seeded && lastSeq !== undefined) catchUp(); + }, + onResyncRequired: () => { + trail.recordEvent('resync', undefined, store.getState()); + catchUp(); + }, + onReconnected: () => { + trail.recordEvent('ack-refresh', undefined, store.getState()); + catchUp(); }, - onResyncRequired: refresh, - onReconnected: refresh, }, }); - store.onGap = refresh; - setChannel({ store }); + store.onGap = () => { + trail.recordEvent('gap', undefined, store.getState()); + catchUp(); + }; + setChannel({ store, trail }); setLoaded(false); setLoadError(null); refresh(); @@ -177,7 +319,7 @@ function useTranscriptChannel( channel?.store.subscribe ?? noopSubscribe, () => channel?.store.getState() ?? EMPTY_AGENT_STATE, ); - return { store: channel?.store ?? null, state, loaded, loadError }; + return { store: channel?.store ?? null, state, trail: channel?.trail ?? null, loaded, loadError }; } export function ChatView({ @@ -205,7 +347,7 @@ export function ChatView({ if (el !== null) anchorRef.current = el.scrollHeight - el.scrollTop; }, []); - const { store, state, loaded, loadError } = useTranscriptChannel( + const { store, state, trail, loaded, loadError } = useTranscriptChannel( sessionId, agentId, ready, @@ -248,6 +390,12 @@ export function ChatView({ pageSize: TRANSCRIPT_PAGE_SIZE, }); store.applyPage(page); + trail?.recordRest( + { beforeTurn: oldest, pageSize: TRANSCRIPT_PAGE_SIZE }, + 'prepend', + page, + store.getState(), + ); } catch (error) { anchorRef.current = null; setOlderError(error); @@ -256,15 +404,40 @@ export function ChatView({ } }; + // Auto-paging: the top sentinel auto-loads the previous REST page when it + // approaches the viewport (paused while a previous load failed — the retry + // button re-arms it). This replaces any manual "load earlier" action. + const loadOlderRef = useRef(loadOlder); + loadOlderRef.current = loadOlder; + const topSentinelRef = useRef(null); + const hasMoreOlder = state.hasMoreOlder; + useEffect(() => { + const sentinel = topSentinelRef.current; + const root = scrollRef.current; + if (sentinel === null || root === null || olderError !== null) return; + const observer = new IntersectionObserver( + (entries) => { + if (entries.some((entry) => entry.isIntersecting)) void loadOlderRef.current(); + }, + { root, rootMargin: '400px 0px 0px 0px' }, + ); + observer.observe(sentinel); + return () => { + observer.disconnect(); + }; + }, [hasMoreOlder, loaded, olderError, loadingOlder]); + const running = state.meta.activity === 'turn' || items.some((item) => item.kind === 'turn' && item.state === 'running'); - // Interactions render inline at their anchor tool frame; entities whose - // anchor frame is outside the loaded window collect here. + // Interactions render inline at their anchor tool frame; entities without + // an anchor (or whose anchor frame is outside the loaded window) collect + // here and render floating at the bottom. const anchoredToolCallIds = collectToolCallIds(items); const unanchoredInteractions = [...state.interactions.values()].filter( - (interaction) => !anchoredToolCallIds.has(interaction.toolCallId), + (interaction) => + interaction.toolCallId === undefined || !anchoredToolCallIds.has(interaction.toolCallId), ); const latestTodo = [...state.todos.values()].at(-1); @@ -279,6 +452,7 @@ export function ChatView({ .agent(agentId) .service(IAgentRPCService) .prompt({ input: [{ type: 'text', text }] }); + trail?.recordEvent('prompt', text, state); } catch (error) { setSendError(error); } @@ -288,6 +462,7 @@ export function ChatView({ if (sessionId === null) return; try { await klient.session(sessionId).agent(agentId).service(IAgentRPCService).cancel({}); + trail?.recordEvent('cancel', undefined, state); } catch (error) { setSendError(error); } @@ -309,102 +484,125 @@ export function ChatView({ } return ( -
-
- {sessionId} - agent: {agentId} - {running ? turn running : idle} - {state.pendingInteractions.size > 0 ? ( - {state.pendingInteractions.size} pending - ) : null} -
- -
- {state.hasMoreOlder ? ( -
- void loadOlder()} disabled={loadingOlder}> - {loadingOlder ? 'Loading…' : 'Load earlier turns'} - -
- ) : null} - {olderError !== null ? ( -
- -
- ) : null} - {loadError !== null ? ( -
- -
- Failed to load the transcript — the server may be too old to expose the transcript - API. -
-
- ) : null} - {items.length === 0 && loadError === null ? ( -
- {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} + +
+
+
+ {sessionId} + agent: {agentId} + {running ? turn running : idle} + {state.pendingInteractions.size > 0 ? ( + {state.pendingInteractions.size} pending + ) : null}
- ) : null} - {latestTodo !== undefined && latestTodo.items.length > 0 ? ( -
-
todo (latest)
- {latestTodo.items.map((entry, i) => ( -
- - {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} - - - {entry.title} + +
+ {state.hasMoreOlder ? ( +
+ + {loadingOlder ? 'Loading earlier turns…' : ''}
+ ) : null} + {olderError !== null ? ( +
+ +
+ { + setOlderError(null); + void loadOlder(); + }} + > + Retry loading earlier turns + +
+
+ ) : null} + {loadError !== null ? ( +
+ +
+ Failed to load the transcript — the server may be too old to expose the transcript + API. +
+
+ ) : null} + {items.length === 0 && loadError === null ? ( +
+ {loaded ? 'Empty transcript — send a prompt below.' : 'Loading transcript…'} +
+ ) : null} + {latestTodo !== undefined && latestTodo.items.length > 0 ? ( +
+
todo (latest)
+ {latestTodo.items.map((entry, i) => ( +
+ + {entry.status === 'done' ? '✔' : entry.status === 'in_progress' ? '◐' : '□'} + + + {entry.title} + +
+ ))} +
+ ) : null} + {items.map((item) => ( + // Native virtual screen: the browser skips layout/paint for + // off-screen items and remembers their last rendered size + // (`auto` in contain-intrinsic-size), so long transcripts stay + // cheap without a windowing library. +
+ +
+ ))} + {unanchoredInteractions.map((interaction) => ( + ))}
- ) : null} - {items.map((item) => ( - - ))} - {unanchoredInteractions.map((interaction) => ( - - ))} -
- -
- {sendError !== null ? ( -
- -
- ) : null} -
-