diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index 51abb8fd86..aff7b32029 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md -2026-07-08-agent-scope-contexts.md: 6a1fd4aed49cb8edef061c8fb6f0edcd0a09c30f -2026-07-08-agent-scope-contexts.zh.md: 8408c4afff6075c129c6a96c47393c9c812b04b7 +2026-07-08-agent-scope-contexts.md: 45e635b7bc3138d4e90a25a06ff23b3b57a9415e +2026-07-08-agent-scope-contexts.zh.md: aac860734e744843c3b9e7d55e5bc7a150763090 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md index 6a1fd4aed4..45e635b7bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md @@ -16,6 +16,8 @@ The mechanism also needs a publication boundary. An agent must not become visibl Every live agent owns one flat registration layer exposed as `agent.ctx`. Code registers through the context that owns a contribution; scope-aware services combine deployment-global registrations with exactly one matching agent layer; operations choose that layer from their real agent; and the layer exists for the agent's complete published lifetime. +`agent.ctx` carries registration ownership and the scope key; it does not expose a reverse `agent` property. Code that needs the domain subject receives it explicitly: `AgentSetup` receives `(agentCtx, agent)`, and scoped events carry their subject in the payload. + Cordis is the plugin framework underneath the SDK. A Cordis **context** is the object plugins use to access services and register effects whose cleanup follows that context. The [Cordis primer](../../../../docs/cordis-primer.md) explains the framework in more detail. For most contributors, the complete contract is four rules: @@ -45,7 +47,7 @@ flowchart LR The missing cross-edges are the isolation rule: Agent A's local registrations do not enter Agent B's view, and a parent's registrations do not enter a child merely because the parent owns the child's lifetime. -The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. +The companion [runtime-design Agent Note](2026-07-12-agent-scope-runtime-design.md) explains the implementation and correctness reasoning. The [explicit runtime-identity Agent Note](2026-08-31-explicit-agent-runtime-identity.md) owns why lifecycle, event, and transport interfaces pass Agent identity instead of exposing it through Context. The [subagent composition-controls Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) owns the separate `persona`, `toolFilter`, and `maxDepth` feature. ### Registration origin chooses visibility and cleanup @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -Setup receives a full trusted Cordis context so it can compose ordinary plugins and services. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. +Setup receives the full trusted Cordis context and unpublished Agent so it can compose ordinary plugins and services while reading the exact child Session when needed. Its contract is composition-only: driving or publishing the in-flight agent through casts or internal registry calls is unsupported. ### The operation chooses the view diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index 8408c4afff..aac860734e 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -16,6 +16,8 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 +`agent.ctx` 携带注册所有权和作用域键,不暴露反向的 `agent` 属性。需要领域主体的代码会显式接收它:`AgentSetup` 接收 `(agentCtx, agent)`,作用域事件则在 payload 中携带主体。 + Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -45,7 +47,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md)阐述实现与正确性推理。[显式运行时身份 Agent Note](2026-08-31-explicit-agent-runtime-identity.zh.md)说明生命周期、事件和传输接口为何显式传递 Agent 身份,而不通过 Context 暴露该身份。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md)负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -88,7 +90,7 @@ await handle.dispose() ctx.tools.get('review_summary', handle.agent) // undefined: scope is gone ``` -setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插件和服务。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 +setup 接收完整的受信 Cordis 上下文和未发布的 Agent,因此既可以组合普通插件和服务,也能在需要时读取确切的子 Session。其约定仅限组合:不支持通过 cast 或内部注册表调用来驱动或发布正在构建中的 agent。 ### 操作选择视图 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index e469f71c7b..dbcffa137d 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md -2026-07-12-agent-scope-runtime-design.md: b6001a5ef9f2dc69ec21908f8350b765dd00acf1 -2026-07-12-agent-scope-runtime-design.zh.md: be12c53ffa9b89e007888935002a5c484c038fd7 +2026-07-12-agent-scope-runtime-design.md: ca300d4eeeab878a4e41b8e68a669be418617181 +2026-07-12-agent-scope-runtime-design.zh.md: 870690d6ace9fefd859557a2e73e88b9b1da6206 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md index b6001a5ef9..ca300d4eee 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md @@ -42,6 +42,8 @@ All agents share one Cordis service graph. A derived context does not clone `Too `agent.ctx` is such a derived context. Service calls still reach the shared instances, while a registration can inspect its calling context and store a contribution under the nearest scope key. Ordinary plugin contexts carry no scope key and therefore register globally. +The Agent context is exactly the context returned by `createScope`; it carries no second reverse association to the Agent. Subject-bearing APIs pass the Agent explicitly, leaving one formal scope mechanism for registration ownership and routing. + ### Fibers and effects make cleanup structural A Cordis fiber is the live instance created when a plugin or child context is activated. Its state records whether that lifecycle is active, unloading, failed, or disposed. `ctx.effect()` and `ctx.on()` return disposers and also attach those disposers to the registering fiber, so unloading a plugin or agent scope removes everything registered through that context without a separate inventory. @@ -68,7 +70,7 @@ A `ScopeKey` is an opaque object compared by identity. The harness uses the live `createScope(parent, key)` returns a scope whose `ctx` shares the parent's services and whose effects are tagged with that key. `scopeOf(ctx)` reads the nearest registration key. `scopeTarget(base, key)` creates the event receiver whose filter preserves the base receiver's Cordis service filter, then admits unscoped listeners and listeners with that exact key. -The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives the explicit event argument; code that needs registration ownership receives `agent.ctx`. +The receiver is a small carrier rather than a transparent proxy for the domain object. Code that needs the agent receives an explicit setup parameter or event argument; code that needs registration ownership receives `agent.ctx`. ### Registry reads overlay one exact layer @@ -100,11 +102,11 @@ The transaction is installed under both the calling Cordis context and the concr Create prepares a new Session. Resume loads and validates the persisted Session before preparing the same live session identity. Both paths then build the scope, agent, and driver and invoke the same setup/publication algorithm. -The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. This preserves dependency origin and caller ownership without stacking trace proxies. +The factory stores concrete trace targets but invokes them through a caller-bound Cordis trace. A runtime child creator sets `parentAgent` in the create or resume options, and AgentRegistry forwards those options without deriving a parent from the caller Context. This preserves dependency origin and both ownership facts without stacking trace proxies or attaching a domain object to the Context. Scoped Remote event adapters likewise receive the Agent in the request, verify that it is the carrier key, and project its Context and wire identity directly. No scope index reconstructs an Agent from a Context. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) owns this separation and the continuable-child ownership rule that follows from it. ### Setup is trusted composition inside a private world -Setup receives the full child context and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, but the public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. +Setup receives the full child context and the exact unpublished Agent, and may await plugin activation. It can register tools, prompt sections, restrictions, listeners, and other effects, and consumers that need the child's Session read it from the Agent parameter. The public contract does not support driving or publishing the in-flight agent through casts or internal registry calls. The transaction races asynchronous load and setup against deactivation rather than waiting forever for a promise owned by external code. If cancellation or owner unload wins, public creation rejects after transaction-owned cleanup even when the external promise never settles. diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index be12c53ffa..870690d6ac 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -42,6 +42,8 @@ Status: implemented `agent.ctx` 就是这样一个派生上下文。服务调用仍然到达共享实例,而注册操作可以检查其调用上下文并将贡献存储在最近的作用域键下。普通的插件上下文不携带作用域键,因此注册到全局。 +Agent 上下文就是 `createScope` 返回的上下文,不携带第二份指回 Agent 的关联。需要主体的 API 显式传递 Agent,因此注册所有权与路由只依赖一种正式的作用域机制。 + ### Fiber 与 effect 使清理成为结构性的 Cordis fiber 是插件或子上下文被激活时创建的活跃实例。其状态记录该生命周期是 active、unloading、failed 还是 disposed。`ctx.effect()` 和 `ctx.on()` 返回 disposer,同时将这些 disposer 附加到注册所在的 fiber,因此卸载一个插件或 agent 作用域会移除通过该上下文注册的一切,无需单独的清单。 @@ -70,7 +72,7 @@ scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个 `createScope(parent, key)` 返回一个作用域,其 `ctx` 共享父级的服务,其 effect 被标记为该键。`scopeOf(ctx)` 读取最近的注册键。`scopeTarget(base, key)` 创建事件接收器,其过滤器保留 base receiver 的 Cordis 服务过滤器,然后接纳无作用域的监听器和具有该确切键的监听器。 -Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的事件参数;需要注册所有权的代码接收 `agent.ctx`。 +Receiver 是一个小型载体而非领域对象的透明代理。需要 agent 的代码接收显式的 setup 参数或事件参数;需要注册所有权的代码接收 `agent.ctx`。 ### 注册表读取叠加一个精确 layer @@ -102,11 +104,11 @@ detach 闭包捕获其确切注册表条目。它仅在映射仍指向该注册 创建准备一个新 Session。恢复加载并验证持久化的 Session,然后准备相同的活跃会话标识。两条路径随后构建作用域、agent 和 driver,并调用相同的 setup/发布算法。 -工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。这保留了依赖来源和调用方所有权,而不堆叠 trace 代理。 +工厂存储具体的 trace 目标,但通过调用方绑定的 Cordis trace 调用它们。运行时子 Agent 的创建方在 create 或 resume options 中设置 `parentAgent`,AgentRegistry 转交这些 options,不从调用方 Context 推导父级。这既保留了依赖来源和两种所有权事实,又不堆叠 trace 代理,也不把领域对象附着到 Context。作用域 Remote 事件适配器同样从 request 接收 Agent,校验它就是 carrier key,再直接投影其 Context 与 wire identity。系统不会通过作用域索引从 Context 重建 Agent。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)拥有这项分离原则及由此确定的可续跑子级归属规则。 ### Setup 是私有世界内的可信组合 -Setup 接收完整的子上下文,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect,但公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 +Setup 接收完整的子上下文和确切的未发布 Agent,可以等待插件激活。它可以注册工具、提示词段、限制、监听器和其他 effect;需要子 Session 的消费者从 Agent 参数读取它。公开约定不支持通过强制转换或内部注册表调用来驱动或发布正在创建中的 agent。 事务将异步加载和 setup 与停用进行竞争,而非无限等待外部代码拥有的 promise。如果取消或所有者卸载获胜,即使外部 promise 永不结算,公开创建也会在事务拥有的清理之后拒绝。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 570b68263c..99577f250e 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md -2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac +2026-07-15-agent-initiator-scope.md: ab11da116a463cd706418e797eb58f1bc4ab9b1c +2026-07-15-agent-initiator-scope.zh.md: 343acba5f99379b6d2c3af41368e8fbe90b20611 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md index 63540c0ec6..ab11da116a 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md @@ -6,7 +6,7 @@ English | [中文](2026-07-15-agent-initiator-scope.zh.md) ## Problem -The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. Changing a root `ctx.agent` to mean “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. +The harness has two useful but different notions of context. A Cordis `Context` selects services, registration ownership, and lifetime; `agent.ctx` is the flat registration scope owned by one live Agent. Agent and Session identity instead describe the subject of an asynchronous operation. A dynamic `ctx.agent` meaning “whichever Agent is running” would conflate those meanings and fail when one process drives Agents concurrently. Deep process-local infrastructure sometimes needs a trusted initiating Agent below explicit loop, tool, and request parameters—for example, a host-aware transport, tracing helper, logger, or gateway client. Requiring every private helper to forward `agent` adds repetition, while a process-global mutable slot is incorrect across `await`. Model-visible arguments are unsuitable because a model must not choose a trusted Session or routing header. The carrier belongs to the Agent service rather than optional model-visible context. @@ -18,9 +18,9 @@ The mandatory `ctx.agents` service uses Node `AsyncLocalStorage` to carry the in `AgentLoop` already injects `ctx.agents` and wraps each concrete driver's complete `runLoop` lifetime in `agents.withInitiator(agent, ...)`. Its package-private loop, turn, step, and tool-call orchestration entries recover the exact Agent from `ctx.agents`, derive `agent.session` once, and let operation-local helpers capture it instead of forwarding the concrete driver or `Session` through shallow interfaces. A leaf helper keeps a narrow `Session` parameter when that is its actual interface rather than accepting a broader `Context` only for an ambient lookup. -Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while `agentCtx.agent` explicitly identifies the child. +Concurrent drivers receive independent stores. A child driver's continuations carry the child, while the caller resumes in its prior store as soon as `withInitiator()` returns; active-run tracking keeps the returned Promise in the teardown drain until it settles. Creation, persistence load, and unpublished `setup(agentCtx, childAgent)` remain outside the child's driver boundary: creation initiated by a parent runs under the parent identity, while the explicit `childAgent` parameter identifies the child. -Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, `GenerateOptions.sessionId`, job ownership, parent/child requests, `ctx.agent`, `agentCtx.agent`, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. +Ambient identity does not replace explicit contracts. `ToolExecution.agent`, `AssembleContext.agent`, the Agent parameter of `AgentSetup`, `GenerateOptions.sessionId`, job ownership, parent/child requests, approval and hook subjects, `cwd` selection, cancellation, worker/process messages, persistence records, and wire identity remain explicit. A remote boundary materializes the identity it needs into its typed request because ALS is process-local. `AgentRegistry` owns an ordered initiator lifecycle. Teardown first rejects new boundaries; removing `ctx.agents` then drains injected dependents such as AgentLoop, and the registry waits for active returned-Promise boundaries before calling `AsyncLocalStorage.disable()`. If a boundary's inherited async chain starts an owning Cordis fiber's unload, the private run-token lineage releases that nested boundary chain from the drain, which prevents teardown from waiting on itself while unrelated boundaries still drain. `currentInitiator()` and `requireInitiator()` remain usable through a retained in-flight service reference while the ordinary drain runs; after disposal, initiator methods throw `agent initiator scope is disposed`. Root Context disposal may start sibling fiber teardown concurrently, so active-boundary counting remains necessary in addition to Cordis dependency ordering. @@ -28,7 +28,7 @@ Initiator scope does not own detached work: registry drain tracks only the Promi A host-aware transport may derive a deployment-owned header such as `X-Harness-Session-Id` from `ctx.agents.requireInitiator().session.id`; the header is absent from model-visible schema and arguments. No production MCP or Web transport adopts such a header in this decision. A test-double transport proves the trusted boundary without assigning host routing policy to an existing provider-neutral seam. -This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. +This decision extends the [Agent registration-scope contract](2026-07-08-agent-scope-contexts.md) and its [runtime design](2026-07-12-agent-scope-runtime-design.md); it does not change their static `agent.ctx` meaning. The [explicit runtime-identity decision](2026-08-31-explicit-agent-runtime-identity.md) keeps initiator scope limited to private asynchronous chains while lifecycle, ownership, event, and wire interfaces carry their subjects directly. ## Verification @@ -40,7 +40,7 @@ A test-double host-aware transport derives `X-Harness-Session-Id` internally and **Pass Agent through every function.** Public, worker, process, persistence, and wire boundaries continue to do this, but requiring every process-local private helper to carry Agent adds repetitive forwarding without improving trust. ALS is confined to the asynchronous chain inside those explicit boundaries. -**Make `ctx.agent` dynamic.** `ctx.agent` already means the static Agent associated with an Agent-scoped Cordis context. Changing the root meaning would mix registration and execution scopes and make concurrent behavior surprising. +**Expose a dynamic `ctx.agent`.** Context carries registration ownership, not a domain subject. Adding an accessor for the executing Agent would mix registration and execution scopes and make concurrent behavior surprising. **Add a separate `ctx.agentExecution` service.** The carrier has no independent backend, configuration, or identity type: it stores the same `Agent` that `ctx.agents` already owns, and AgentLoop already depends on that service. A second mandatory provider would add package, composition, lifecycle, generated-catalog, and test-harness wiring without separating a real capability. diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index 3ea893aa5f..343acba5f9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若把根 `ctx.agent` 改成「当前正在运行的 Agent」,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 +harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负责选择服务、注册归属和生命周期;`agent.ctx` 是一个存活 Agent 所拥有的扁平注册作用域。Agent 与会话身份描述的则是异步操作主体。若提供表示「当前正在运行的 Agent」的动态 `ctx.agent`,就会混淆这两种含义,并在单进程并发驱动多个 Agent 时失效。 进程内深层基础设施有时需要在显式传递的循环、工具及请求参数之下获取可信的发起 Agent,例如宿主感知传输层、追踪辅助函数、日志器或网关客户端。要求每个私有辅助函数都转发 `agent` 会造成重复,而进程级可变槽会在跨 `await` 时发生并发错误。模型可见参数也不适用,因为模型不得选择可信的会话或路由请求头。该载体归 Agent 服务所有,而非模型可见的可选上下文。 @@ -18,9 +18,9 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 `AgentLoop` 已经注入 `ctx.agents`,并用 `agents.withInitiator(agent, ...)` 包裹每个具体驱动的完整 `runLoop` 生命周期。循环、轮次、步骤和工具调用的包内私有入口从 `ctx.agents` 恢复同一个 Agent,一次推导 `agent.session`,再由操作内辅助函数捕获该值,避免在浅层接口中转发具体驱动或 `Session`。若 `Session` 本身就是底层辅助函数的实际接口,该函数会保留狭窄的 `Session` 参数,而不会只为隐式查找而接收更宽泛的 `Context`。 -因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而 `agentCtx.agent` 显式标识子 Agent。 +因此,并发驱动使用彼此独立的存储。子驱动的异步延续携带子 Agent;`withInitiator()` 返回后,调用方立即恢复之前的存储,而活动运行计数仍持续跟踪返回的 Promise,直到其结束。创建、持久化加载和尚未发布的 `setup(agentCtx, childAgent)` 位于子驱动边界之外:由父 Agent 发起的创建使用父身份,而显式的 `childAgent` 参数标识子 Agent。 -隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`GenerateOptions.sessionId`、任务归属、父子请求、`ctx.agent`、`agentCtx.agent`、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 +隐式身份不会取代显式约定。`ToolExecution.agent`、`AssembleContext.agent`、`AgentSetup` 的 Agent 参数、`GenerateOptions.sessionId`、任务归属、父子请求、审批与 hook 主体、`cwd` 选择、取消、worker 和进程消息、持久化记录及协议身份都保持显式传递。远程边界会把所需身份写入类型化请求,因为 ALS 只在进程内有效。 `AgentRegistry` 管理一个有序的发起方生命周期。teardown 会先拒绝新边界;移除 `ctx.agents` 后,AgentLoop 等注入方开始排空,注册表随后等待活动的返回 Promise 边界,最后调用 `AsyncLocalStorage.disable()`。如果某个边界继承的异步调用链启动所属 Cordis fiber 的卸载,私有运行标记谱系会从排空范围中释放该嵌套边界链,从而避免 teardown 等待自身完成,同时继续排空无关边界。在普通排空期间,进行中代码可通过保留的服务引用继续调用 `currentInitiator()` 和 `requireInitiator()`;dispose(资源释放)后,发起方方法会抛出 `agent initiator scope is disposed`。根 Context dispose 可能并发启动同级 fiber 的 teardown,因此除 Cordis 依赖顺序外仍必须统计活动边界。 @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。[显式运行时身份决策](2026-08-31-explicit-agent-runtime-identity.zh.md)把发起方作用域限制在私有异步调用链内,同时让生命周期、归属、事件和协议接口直接携带各自的主体。 ## 验证 @@ -40,7 +40,7 @@ Agent 服务测试锁定可选与必需读取、同步值及跨 realm Promise **在每个函数中传递 Agent。** 公开、worker、进程、持久化和协议边界继续显式传递,但要求每个进程内私有辅助函数都携带 Agent 只会造成重复转发,不会提高可信度。ALS 仅限于这些显式边界内部的异步调用链。 -**让 `ctx.agent` 变成动态值。** `ctx.agent` 已经表示与 Agent 作用域 Cordis 上下文静态关联的 Agent。改变根上下文的含义会混合注册作用域与执行作用域,并让并发行为变得意外。 +**暴露动态的 `ctx.agent`。** Context 携带注册所有权,而非领域主体。为正在执行的 Agent 新增 accessor 会混合注册作用域与执行作用域,并让并发行为变得意外。 **新增独立的 `ctx.agentExecution` 服务。** 该载体没有独立后端、配置或身份类型:它存储的是 `ctx.agents` 已经管理的同一个 `Agent`,而 AgentLoop 本就依赖该服务。第二个必需提供方会增加包、组合、生命周期、生成目录及测试 harness 接线,却没有拆出真实能力。 diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml new file mode 100644 index 0000000000..4fac92ed8c --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md +2026-08-31-explicit-agent-runtime-identity.md: f52b8ec116c312a27306fe73dc0bd5b99fcd9039 +2026-08-31-explicit-agent-runtime-identity.zh.md: 6b6fd2f2ea1f1645069264f09fd53c3f71e1d02f diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md new file mode 100644 index 0000000000..f52b8ec116 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.md @@ -0,0 +1,47 @@ +# Agent Note: Explicit Agent identity at runtime boundaries + +Status: implemented + +English | [中文](2026-08-31-explicit-agent-runtime-identity.zh.md) + +## Problem + +An Agent's Cordis Context owns registrations and their cleanup. Agent identity instead selects the Session, runtime owner, event subject, authority decision, or wire identity for one operation. A reverse Agent property on Context made those two facts appear interchangeable: a caller could choose a Context for effect ownership and accidentally let that choice determine domain identity. + +The reverse association also required compensating mechanisms after type erasure. Host Remote forwarding inspected a routed subject for its Context, creation inferred runtime parentage from the caller Context, and adapters maintained reverse identity scans. These mechanisms duplicated identity already present in typed requests and obscured which caller owned an Agent at runtime. + +Without an explicit owner, `SubagentContinuationManager` creates and resumes children through its private plugin Context, so Context-based inference classifies every continuable child as a runtime root even though the manager holds its exact parent. Root-only consumers could then attach scheduling tools, grant direct-human goal authority, or route user questions as if the child were top-level. + +## Decision + +Runtime interfaces carry Agent identity at the point that owns it. `AgentSetup` receives `(agentCtx, agent)`; Agent creation and resume options carry `parentAgent` for a runtime child; scoped events carry their Agent in the payload; Remote forwarding verifies that `request.agent` is the carrier key; and Host Typert Context resolution maps wire identity to a live Agent Context without a reverse scan. `agent.ctx` remains the registration and lifecycle owner and exposes no reverse Agent property. + +Scope-aware registries continue to use the opaque scope key only for registration membership. Tool-subagent does not classify that key or resolve an Agent from Context. A direct `AgentSetup` passes the unpublished Session explicitly and installs through the supplied Context before publication. For a settings-backed standing preset, the event payload supplies the Agent, its Session supplies the policy target, and its Context owns the registrations. + +`SubagentContinuationManager` puts the exact parent in both fresh-creation and cold-resume options. A live continuable child is therefore excluded from `AgentRegistry.roots()` and satisfies `isOwnedBy(child.id, parent)`. Durable `parentSession` metadata does not substitute for this relation: a fork or resumed Session may be a runtime root when no live Agent owns it. + +The [Agent registration-scope decision](2026-07-08-agent-scope-contexts.md), its [runtime design](2026-07-12-agent-scope-runtime-design.md), and the [initiator-scope decision](2026-07-15-agent-initiator-scope.md) retain their independent registration, lifecycle, and private-chain rationale. This decision supersedes only the reverse Context association and implicit runtime-owner derivation described there. + +## Verification + +Agent creation tests pin explicit root and child ownership. Continuation integration tests keep a real child live long enough to assert both `roots()` exclusion and `isOwnedBy()` membership. Existing Schedule tests verify that root-only registrations stay absent from an explicitly owned child. + +Remote-event tests reject a missing or mismatched Agent before forwarding a scoped waterfall. Tool-subagent tests verify that direct setup installs before Session publication; standing-preset tests verify per-Session policy sampling and inheritance. + +## Alternatives considered + +**Keep `Context.agent`.** A reverse accessor makes registration ownership look like operation identity and requires every Context derivation, adapter, and test double to preserve an association unrelated to Cordis service selection or effect cleanup. + +**Infer runtime ownership from the caller Context.** A private manager Context, an Agent Context, and a standing preset Context can all call the same factory. Context ancestry therefore does not state which live Agent owns the result; the creator must put the parent it already knows in the request options. + +**Classify Agent scope keys.** An opaque scope key states routing membership, not domain identity. Classifying it would make Agent the center of composition and would still couple a plugin's effect owner to the Session whose policy it needs. + +**Use the initiating Agent as creation ownership.** Initiator scope records causal asynchronous execution, not lifetime ownership. A parent may initiate work that intentionally creates a root, and setup remains outside the child's driver boundary. + +**Use durable Session lineage.** `parentSession` records conversation ancestry across process lifetimes. Runtime ownership controls live roots and teardown, so equating the two would prevent a legitimately resumed fork from becoming a top-level Agent. + +## Consequences + +Lifecycle options, events, service requests, and transport requests carry explicit Agent identities, so each operation states the identity it uses and TypeScript checks both sides. Context remains reusable for dependency access and effect ownership without becoming an alternate domain-object locator. + +Continuable children have the same runtime parent relation as one-shot in-process children. Root-only consumers exclude them, parent teardown can reason from one live ownership graph, and durable lineage remains free to describe history rather than process-local lifetime. diff --git a/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md new file mode 100644 index 0000000000..6b6fd2f2ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-31-explicit-agent-runtime-identity.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 运行时边界显式携带 Agent 身份 + +Status: implemented + +[English](2026-08-31-explicit-agent-runtime-identity.md) | 中文 + +## 问题 + +Agent 的 Cordis Context 拥有注册及其清理。Agent 身份则为某项操作选择会话、运行时所属方、事件主体、权限决策或协议身份。Context 上反向的 Agent 属性让这两个事实看起来可以互换:调用方选择用于管理 effect 所有权的 Context 时,可能意外地让该选择决定领域身份。 + +类型信息被擦除后,这项反向关联还需要补偿机制。Host Remote 转发会从已路由主体检查其 Context,创建流程会从调用方 Context 推断运行时父级,适配器则维护反向身份扫描。这些机制重复类型化请求中已有的身份,也掩盖了哪个调用方在运行时拥有 Agent。 + +若没有显式所属方,`SubagentContinuationManager` 会通过私有插件 Context 创建和恢复子级,因此基于 Context 的推断会把每个可续跑子级归类为 runtime root,尽管管理器持有其确切父级。仅限根级的消费方随后可能附加调度工具、授予直接人类输入对应的 Goal 权限,或像处理顶层 Agent 一样路由用户问题。 + +## 决策 + +运行时接口在拥有身份的位置携带 Agent 身份。`AgentSetup` 接收 `(agentCtx, agent)`;创建与恢复 Agent 的 options 通过 `parentAgent` 标识运行时子级;作用域事件在 payload 中携带 Agent;Remote 转发校验 `request.agent` 就是 carrier key;Host Typert Context 解析则把协议身份映射到存活 Agent Context,不执行反向扫描。`agent.ctx` 继续拥有注册和生命周期,不暴露反向 Agent 属性。 + +感知作用域的注册表继续仅使用不透明作用域键判断注册成员关系。tool-subagent 不会分类该键,也不会从 Context 解析 Agent。直接 `AgentSetup` 显式传入尚未发布的 Session,并在发布前通过所给 Context 完成安装。对于由设置控制的常驻 preset,事件 payload 提供 Agent,其 Session 提供策略目标,其 Context 拥有注册项。 + +`SubagentContinuationManager` 会把确切父级放进全新创建与冷恢复的 options。因此,存活的可续跑子级不会出现在 `AgentRegistry.roots()` 中,并且满足 `isOwnedBy(child.id, parent)`。持久化 `parentSession` 元数据不能代替这项关系:没有存活 Agent 拥有 fork 或已恢复会话时,它仍可成为 runtime root。 + +[Agent 注册作用域决策](2026-07-08-agent-scope-contexts.zh.md)、其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md)和[发起方作用域决策](2026-07-15-agent-initiator-scope.zh.md)继续拥有各自独立的注册、生命周期及私有调用链理由。本决策只取代其中描述的反向 Context 关联和隐式运行时所属方推导。 + +## 验证 + +Agent 创建测试锁定显式的根级与子级归属。continuation 集成测试让一个真实子级保持存活,直到断言其既不属于 `roots()`、又满足 `isOwnedBy()`。现有 Schedule 测试验证仅限根级的注册项不会出现在显式归属的子级中。 + +Remote 事件测试会在转发作用域 waterfall 前拒绝缺失或不匹配的 Agent。tool-subagent 测试验证 direct setup 会在 Session 发布前完成安装;常驻 preset 测试验证逐 Session 的策略读取与继承。 + +## 考虑过的替代方案 + +**保留 `Context.agent`。** 反向 accessor 会让注册所有权看起来等同于操作身份,还要求每个 Context 派生、适配器和测试替身保留一项与 Cordis 服务选择或 effect 清理无关的关联。 + +**从调用方 Context 推断运行时归属。** 私有管理器 Context、Agent Context 和常驻 preset Context 都能调用同一个工厂。因此,Context 祖先关系无法说明由哪个存活 Agent 拥有结果;创建方必须把它已知的父级放进请求 options。 + +**分类 Agent 作用域键。** 不透明作用域键表达路由成员关系,而不是领域身份。分类该键会让 Agent 成为组合中心,也仍会把插件的 effect 所有者与策略所需的 Session 耦合起来。 + +**使用发起 Agent 作为创建归属。** 发起方作用域记录异步执行的因果关系,而非生命周期归属。父级可能发起有意创建根级 Agent 的工作,而 setup 仍位于子级驱动边界之外。 + +**使用持久化会话谱系。** `parentSession` 跨进程生命周期记录对话祖先关系。运行时归属控制存活根级和 teardown,因此把二者等同会阻止合法恢复的 fork 成为顶层 Agent。 + +## 后果 + +生命周期 options、事件、服务请求和传输请求会携带显式 Agent 身份,因此每项操作都会声明自身使用的身份,TypeScript 也会检查两侧。Context 可以继续复用于依赖访问与 effect 所有权,而不会成为另一种领域对象定位器。 + +可续跑子级与一次性进程内子级使用同一种运行时父级关系。仅限根级的消费方会排除这些子级,父级 teardown 可以依据唯一的存活归属图推理,而持久化谱系仍可描述历史,不必承担进程内生命周期语义。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml new file mode 100644 index 0000000000..06d0950c8f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md +2026-09-07-typert-package-local-forwarding-imports.md: f50dc7bfc8c9d83c2b6f2b584e1d1119b8df817b +2026-09-07-typert-package-local-forwarding-imports.zh.md: 7e012d220df3e7356b35a105784f89e4df148802 diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md new file mode 100644 index 0000000000..f50dc7bfc8 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.md @@ -0,0 +1,29 @@ +# Agent Note: Follow package-local forwarding modules in Typert references + +Status: implemented + +English | [中文](2026-09-07-typert-package-local-forwarding-imports.zh.md) + +## Problem + +`WorkspaceAnalyzer` resolves every type reference to its original declaration before classifying it, then reads only the referencing file's own `import` statement to decide whether the reference crossed a package through a public export. A package that re-exports another package's type from one of its own modules, and imports that module by relative path elsewhere, therefore fails with `crosses a package without an explicit package import` although the package import exists one hop away. The failure is deterministic for every batch size and package order; it surfaces in whichever analysis selects the referencing package as a root, which is why [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) observed it as batch-dependent. + +## Decision + +[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) resolves a relative specifier through the face's shared compiler host and module-resolution cache and follows it only while the resolved file stays inside the referencing package. In each forwarding module it collects the `export` edges that carry the requested name: a named re-export with a specifier, an `export { local }` backed by that module's `import`, and star re-exports whose module exports the same symbol. Explicit edges are tried before star edges, matching TypeScript's shadowing of star exports, and each resolved module and requested export-name pair is entered once, so circular star re-exports terminate while distinct renamed routes through one module remain available. The walk stops at the first package specifier and feeds that identity and export name to the existing `packageExportName` check, so a forwarded type must still be public at the package subpath the forwarding module names, and a package name without a registration is refused there. The reference model is unchanged: the target remains `declaration` for a same-face owner and `cross-face` for another face. + +The walk yields no package import, and the reference fails as before, when a relative specifier resolves outside the referencing package, when the only edge carrying the name is a namespace re-export or a re-exported namespace import, or when every edge loops back to a module and requested-name pair already entered. + +## Alternatives considered + +**Treat a relative import whose alias chain ends in another package as implicitly public.** Rejected: it would accept `../../other/src/file.ts` and any forwarding module that itself reaches the other package by relative path, removing the public-export check the generated Remote declarations rely on to name an importable subpath. + +**Record the forwarding module as the reference target.** Rejected: emitters and cross-face links need the original declaration's package and public subpath; a package-local module has no public identity of its own. + +**Select edges in source order without symbol checks.** Rejected: a star re-export that loops back to an earlier module can precede the explicit re-export that actually carries the type, and TypeScript itself lets explicit exports shadow star exports; ordering explicit edges first and continuing past an entered module and requested-name pair keeps such modules accepted without an unbounded walk. + +**Make batched and whole-workspace analysis select the same roots.** Rejected as a fix: root selection does not change the verdict on a reference, only whether the reference is visited, so aligning the callers would hide the incorrect classification rather than remove it. + +## Consequences + +Packages may keep one forwarding module for foreign types and import it relatively, matching how their own modules are organized. Each cross-package relative reference costs one module resolution per hop through the face's shared resolution cache; `reachableFiles` now resolves through the same cache. [`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) pins named, renamed multi-hop, import-then-export, star, and namespace-import forwarding, an explicit re-export beside a looping star edge, distinct renamed routes through one shared module, a forwarded private export, a forwarding module that crosses by relative path, a cycle whose only exit crosses by relative path, a namespace re-export, a re-exported namespace import, cross-face forwarding, and equality of whole and batched analysis for the forwarding fixture across batch sizes and package orders. diff --git a/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md new file mode 100644 index 0000000000..7e012d220d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-07-typert-package-local-forwarding-imports.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Typert 引用追踪包内转发模块 + +Status: implemented + +[English](2026-09-07-typert-package-local-forwarding-imports.md) | 中文 + +## Problem + +`WorkspaceAnalyzer` 先把每个类型引用解析到原始声明再分类,然后只读引用所在文件自己的 `import` 语句来判断该引用是否经由公开导出跨包。一个包若在自己的某个模块里重新导出另一个包的类型,并在别处用相对路径导入该模块,就会报 `crosses a package without an explicit package import`,尽管包导入只隔一跳。这个失败在任何批次大小和包顺序下都会稳定出现;它出现在哪次分析里,取决于哪次分析把引用方的包选为根,因此 [issue 3525](https://github.com/deepseek-harness/deepseek-harness/issues/3525) 观察到的现象像是与批次相关。 + +## Decision + +[`targetForReference`](../../../../packages/typert/generator/src/analyzer.ts) 通过该 face 共享的编译器宿主及其模块解析缓存来解析相对说明符,且只在解析到的文件仍位于引用方包内时继续追踪。在每个转发模块里,它收集承载所请求名字的 `export` 边:带说明符的具名重新导出、由该模块自身 `import` 支撑的 `export { local }`,以及导出同一符号的星号重新导出。显式边先于星号边尝试,与 TypeScript 中显式导出遮蔽星号导出的规则一致;解析后的模块与请求导出名组成的每个组合只进入一次,因此循环的星号重新导出能够终止,经同一模块转发的不同改名路径仍可继续尝试。追踪在遇到第一个包说明符时停止,并把该包身份和导出名交给现有的 `packageExportName` 检查,因此被转发的类型仍必须在转发模块所写的包子路径上公开,没有登记的包名也在此被拒绝。引用模型不变:同 face 的所有者仍是 `declaration`,另一 face 仍是 `cross-face`。 + +当相对说明符解析到引用方包之外、承载该名字的唯一边是命名空间重新导出或被重新导出的命名空间导入,或所有边都回到已进入的模块与请求名组合时,追踪得不到包导入,引用照旧失败。 + +## Alternatives considered + +**把别名链终点在另一个包的相对导入视为隐式公开。** 已拒绝:这会接受 `../../other/src/file.ts`,也会接受自身用相对路径抵达另一个包的转发模块,从而取消公开导出检查,而生成的 Remote 声明依赖该检查来命名可导入的子路径。 + +**把转发模块记为引用目标。** 已拒绝:发射器和跨 face 链接需要原始声明的包和公开子路径,包内模块没有自己的公开身份。 + +**按源码顺序选边且不校验符号。** 已拒绝:回到更早模块的星号重新导出可能排在真正承载该类型的显式重新导出之前,而 TypeScript 本身允许显式导出遮蔽星号导出;显式边优先并跳过已进入的模块与请求名组合,既能接受这类模块,又不会无限追踪。 + +**让分批分析与全工作区分析选择相同的根。** 作为修复方案已拒绝:根的选择不改变对一个引用的判定,只决定该引用是否被访问,对齐调用方只会掩盖错误分类,不能消除它。 + +## Consequences + +包可以为外部类型保留一个转发模块并用相对路径导入它,与自身模块的组织方式一致。每个跨包相对引用每跳付出一次经该 face 共享解析缓存的模块解析;`reachableFiles` 现在也通过同一缓存解析。[`type-model.spec.ts`](../../../../packages/typert/generator/tests/type-model.spec.ts) 固定了具名、改名多跳、先导入再导出、星号和命名空间导入这几种转发,与回环星号边并存的显式重新导出,经同一模块转发的不同改名路径,被转发的私有导出,用相对路径跨包的转发模块,唯一出口用相对路径跨包的循环,命名空间重新导出,被重新导出的命名空间导入,跨 face 转发,以及转发 fixture 在不同批次大小和包顺序下全量分析与分批分析相等。 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml index 0fa0a4cd7e..2f5a36785b 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md -2026-08-25-promote-open-anywhere-plugin.md: ee83c424d1454b26c1ce6cf6954105cdbfbb7419 -2026-08-25-promote-open-anywhere-plugin.zh.md: f1696cec10a683d44dcaa3db454d343821fc13c9 +2026-08-25-promote-open-anywhere-plugin.md: 83888cb548046cd8e023cd2b7c87f123cda0ed0f +2026-08-25-promote-open-anywhere-plugin.zh.md: 4ef476ce7043d2dcee05dec9f604849741ce5e96 diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md index ee83c424d1..83888cb548 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.md @@ -12,6 +12,8 @@ The community plugin `@dsh-plugins/open-anywhere` (gitlab.deepseek.com/Ciyou/dsh The first-party feature is named `open-in-app`: it selects the application that opens a workspace directory on the Harness host, not another machine or destination. +The shared `launchedThroughSsh()` predicate in [launch-environment](../../../../packages/util/launch-environment/README.md) reads non-empty `SSH_CONNECTION` or `SSH_TTY` only from the inherited process layer. An SSH launch produces an empty application catalog before any probe. Project and user `.env` values cannot establish an SSH launch; Web browser handoff and the adaptive directory picker use the same predicate. The client hides the action even when it remembers a choice, and the existing availability checks reject icon and launch requests. SSH port forwarding changes HTTP reachability, not which machine owns the workspace or applications. + The feature's first-party owners are `@deepseek-ai/dsh-host-open-in-app` at `packages/host/open-in-app/` (the probe, catalog, and launch routes) and `@deepseek-ai/dsh-client-ui-open-in-app` at `packages/client/ui-open-in-app/` (the split button), mounted in the Web profile by the `dsh-web-app` bundle rows `open-in-app` and `ui-open-in-app`. The promotion is a rewrite, not a vendoring: - **A host/client package pair, following the `directory-picker-browse`/`ui-directory-picker-browse` pairing**: the host package's `src/index.ts` registers the three HTTP routes on `ctx.webServer` (`GET /open-in-app/apps`, `GET /open-in-app/icon/`, `POST /open-in-app/open`); the ui package's `src/client/index.ts` registers the split button into `conversation.session.header.utilities` through the standard slot/inject currency, with copy in a typed `open-in-app` locale namespace and styling in CSS Modules over `--dsw-*` tokens (the original's hand-injected style tag and inline dropdown are replaced by the `Menu` primitive), over an empty-apply node half that keeps the plugin on the host roster. Route paths and wire payload types have one home, the host package's browser-safe `./shared` subpath (constants and types only); the client bundle inlines it through an `INLINE_SAFE` entry in the client tsdown preset, the same channel `dsh-session`'s wire slices use. The host root exports only the Loader-required plugin values and types; catalog, resolver, launcher, and icon helpers remain source-internal. @@ -28,6 +30,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t ## Alternatives considered +**Offer VS Code's remote CLI during SSH sessions.** Its installed executable does not prove a usable editor connection: the inherited IPC socket belongs to a live VS Code connection and can disappear while Harness keeps running. Browser-side SSH-target configuration and local editor handoff remain outside this host-application feature. + **Vendor the plugin's `lib/` as-is under `packages/`.** Fastest, but the hand-authored JavaScript fails typecheck, coverage, i18n, JSDoc, and invariant gates wholesale; keeping it exempt would create a package class the repository deliberately does not have. **A Typert Remote instead of raw webServer routes.** The apps/open calls fit the Remote RPC shape, but the icon route serves binary PNGs, which the JSON RPC vocabulary does not carry; splitting icons onto a raw route while apps/open ride Remote gives two transports for one feature. Raw routes also match the original's client, and `webhook-github` establishes the validated-raw-route pattern. @@ -50,8 +54,8 @@ The pair lives in `packages/host/` and `packages/client/` because that is what t ## Consequences -- The Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null). +- Outside SSH sessions, the Web profile gains the header button wherever the host probes at least one installed catalog application on macOS, Windows, or Linux, with zero rendering elsewhere (empty probed catalog → the component returns null). - The community plugin's install path remains valid but redundant; its original routes and browser choice key are separate from `open-in-app`, so installations using the first-party feature should remove the community plugin to avoid duplicate header controls. - Resolution and icons run lazily, once per host process, so an application installed while dsh runs appears only after restart — accepted; the uninstall direction self-heals through the `ENOENT` single-entry refresh. - The catalog is compile-time fixed; extending it means editing `OPEN_IN_APP_CATALOG` and both locale dictionaries together (README Known Limitations). Platform coverage is uneven — several Git GUIs and terminals are macOS-only entries, Windows icons are limited to the 32px stock .NET extraction, Linux follows hicolor rather than the active theme, and CLI-only entries without a desktop record keep the generic icon. -- Coverage: resolver logic (every locator kind over temp filesystems, registry-dump and desktop-entry fixtures, an injected env/home/PATH table), per-platform icon extraction, the three routes (real Loader + real WebServer composition, including the one-pass cache, the `ENOENT` refresh, and HMR-safety disposal), controller wire behavior, and component presentation are unit-tested to the per-file 100% gate; no snapshot is added because the shipped keyless snapshot fixtures assert session-driven output, which this browser-side control never touches. The web ARIA goldens disable the `open-in-app` and `ui-open-in-app` rows, and the Host-only preset e2e composition disables the host row: the button reflects whatever applications the running machine has installed, so its presence and label are host facts no cross-platform golden can pin. +- Resolver, icon, route, controller, and component tests cover platform discovery, launch outcomes, the availability cache, and HMR disposal. The [SSH Web snapshot](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) renders the shared recorded conversation with both Open In rows enabled and a remembered app choice, capturing only the Session header; composer and statistics output belong to their own snapshots. Inherited SSH markers make the empty catalog deterministic across platforms. Ordinary Web snapshots keep host-dependent application discovery disabled. diff --git a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md index f1696cec10..4ef476ce70 100644 --- a/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-08-25-promote-open-anywhere-plugin.zh.md @@ -12,6 +12,8 @@ Status: implemented 第一方功能命名为 `open-in-app`:它选择在 Harness 主机上打开 workspace 目录的应用,不表示另一台机器或目的位置。 +[launch-environment](../../../../packages/util/launch-environment/README.zh.md) 中共用的 `launchedThroughSsh()` 只从继承的进程层读取非空 `SSH_CONNECTION` 或 `SSH_TTY`。SSH 启动时会在任何探测开始前返回空应用目录。项目与用户 `.env` 中的值不能作为 SSH 启动的依据;Web 浏览器唤起和自适应目录选择器共用此判断。即使客户端记住了应用选择,也会隐藏操作入口;已有的可用性检查会拒绝图标和启动请求。SSH 端口转发只改变 HTTP 可达性,不改变工作区或应用所属的机器。 + 该功能的第一方归属是一对包:`@deepseek-ai/dsh-host-open-in-app` 位于 `packages/host/open-in-app/`(探测、目录与启动路由),`@deepseek-ai/dsh-client-ui-open-in-app` 位于 `packages/client/ui-open-in-app/`(分体按钮),由 `dsh-web-app` bundle 的 `open-in-app` 与 `ui-open-in-app` 两行挂载进 Web profile。转正是重写,不是 vendoring: - **一对 host/client 包,沿用 `directory-picker-browse`/`ui-directory-picker-browse` 的配对结构**:host 包的 `src/index.ts` 在 `ctx.webServer` 上注册三条 HTTP 路由(`GET /open-in-app/apps`、`GET /open-in-app/icon/`、`POST /open-in-app/open`);ui 包的 `src/client/index.ts` 经标准 slot/inject 通货把分体按钮注册进 `conversation.session.header.utilities`,文案在类型化的 `open-in-app` locale 命名空间中,样式为 `--dsw-*` token 上的 CSS Modules(原插件手工注入的 style 标签与内联下拉被 `Menu` 原语替代),节点半边是让插件出现在主机名册上的空 apply。路由路径与 wire 载荷类型只有一个家:host 包浏览器安全的 `./shared` 子路径(只有常量与类型);client bundle 经 client tsdown preset 的 `INLINE_SAFE` 条目将其内联,与 `dsh-session` 各 wire 切片同一通道。host 根入口只导出 Loader 所需的插件实体与类型;目录、resolver、launcher 与图标 helper 保持源码内部可见。 @@ -28,6 +30,8 @@ Status: implemented ## 考虑过的替代方案 +**在 SSH 会话中提供 VS Code 的远端 CLI。** 已安装的可执行文件不能证明编辑器连接可用:继承的 IPC socket 属于一个仍在运行的 VS Code 连接,Harness 继续运行时它也可能消失。浏览器侧的 SSH 目标配置与本地编辑器唤起不属于这个主机应用功能。 + **将插件的 `lib/` 原样 vendor 进 `packages/`。** 最快,但手写 JavaScript 会整体不过 typecheck、覆盖率、i18n、JSDoc 和 invariant 门禁;为其保留豁免会造出仓库刻意不设的包类别。 **用 Typert Remote 而非裸 webServer 路由。** apps/open 调用符合 Remote RPC 形态,但 icon 路由提供二进制 PNG,JSON RPC 词汇承载不了;把 icon 拆去裸路由而 apps/open 走 Remote 会让一个功能有两种传输。裸路由也匹配原插件的客户端,且 `webhook-github` 已确立带校验裸路由的先例。 @@ -50,8 +54,8 @@ Status: implemented ## 后果 -- 只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。 +- 非 SSH 会话中,只要主机在 macOS、Windows 或 Linux 上探测到至少一个已安装的目录应用,Web profile 就会出现头部按钮;其余情况零渲染(探测目录为空 → 组件返回 null)。 - 社区插件的安装路径仍然有效但已冗余;其原始路由与浏览器选择键独立于 `open-in-app`,因此使用第一方功能的安装应移除社区插件,避免出现重复的头部控件。 - 解析与图标每主机进程惰性执行一次,dsh 运行期间安装的应用要重启后才出现——接受;卸载方向经 `ENOENT` 单条目刷新自愈。 - 目录在编译期固定;扩展它意味着同时编辑 `OPEN_IN_APP_CATALOG` 与两份 locale 词典(README 已知限制)。平台覆盖不均——若干 Git GUI 与终端仅有 macOS 条目;Windows 图标受限于 .NET 标准接口的 32px 提取,Linux 跟随 hicolor 而非当前主题,没有 desktop 记录的纯 CLI 条目则保留通用图标。 -- 覆盖:resolver 逻辑(每种 locator 在临时文件系统上、注册表转储与 desktop 条目 fixture、注入的 env/home/PATH 表)、逐平台图标提取、三条路由(真实 Loader + 真实 WebServer 组合,含单趟缓存、`ENOENT` 刷新与 HMR 安全处置)、controller wire 行为和组件呈现都以逐文件 100% 门禁做了单元测试;不新增 snapshot,因为随仓库发布的免密 snapshot fixture 断言会话驱动的输出,而这个纯浏览器侧控件不触及它。Web ARIA golden 禁用 `open-in-app` 与 `ui-open-in-app` 两行,Host-only 的 preset e2e 组合禁用 host 行:按钮反映运行机器实际安装了哪些应用,其出现与否和标签都是主机事实,跨平台 golden 无法钉住。 +- 解析器、图标、路由、控制器与组件测试覆盖平台探测、启动结果、可用性缓存和 HMR 处置。[SSH Web 快照](../../../../snapshots/web/open-in-app-ssh/snapshot.yml) 在启用两个 Open In 配置项并记住应用选择的条件下渲染共享的录制会话,并仅捕获会话头部;输入框和统计栏由各自的快照负责。继承的 SSH 标记使空应用目录在不同平台上保持确定。普通 Web 快照仍禁用依赖主机的应用探测。 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml new file mode 100644 index 0000000000..c52f0a47a2 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md +2026-09-07-session-prose-local-media-display.md: ed740acd0d0d6eaf7f8834dc8d6280a33305aecd +2026-09-07-session-prose-local-media-display.zh.md: de8a7f99d7aabc4474f525f9f37f50a465a80840 diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md new file mode 100644 index 0000000000..ed740acd0d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.md @@ -0,0 +1,43 @@ +# Agent Note: Session prose local media paths display through a same-origin file route + +Status: implemented + +English | [中文](2026-09-07-session-prose-local-media-display.zh.md) + +## Problem + +Assistant prose can reference an image by its filesystem path, but browsers cannot read Host files. A renderer limited to absolute HTTP(S) destinations leaves those references as inert alt text. Issue #3662 records this display gap. + +## Decision + +Local media paths in Session prose render through a same-origin file route. This note owns the renderer vocabulary and its placement; [authenticated filesystem reads](2026-09-08-file-display-through-filesystem.md) owns the current serving policy and supersedes the workspace/media restrictions described below. + +`ui-primitives` owns the `MarkdownPathImages` vocabulary on `MarkdownText`. Like `fileMentions`, it applies only after a message settles so frozen streaming blocks cannot cache a vocabulary handler. The settled pass rewrites image destinations outside the remote-URL allowlist and emits only absolute `http(s)`, `blob`, or `data` results. Without a vocabulary, local destinations retain inert alt text. Failed loads replace the image with authored alt text, or its original destination when alt is empty; a different source can load again. + +`ui-chat` supplies a page-stable `localPathMediaUrl` vocabulary through `AssistantMarkdown`. It maps absolute POSIX paths to `/api/file?path=…` on the page's origin. Relative and protocol-relative paths, Windows-style paths, and non-HTTP page transports such as Electron `file://` remain inert. + +`session-controller` owns the `SessionMediaReferences` contribution beside `SessionFileReferences`. It registers through `connection.fetch`, which applies the same browser authentication and trust checks as `/api` RPC. The fixed same-origin endpoint gives the synchronous renderer a stable URL without an asynchronous capability negotiation. + +## Alternatives considered + +**Typert gateway or workspace controller ownership.** The gateway owns Remote RPC dispatch, while the workspace controller owns registry lifecycle. Neither owns file-byte presentation; Session Controller is the consumer serving Session prose. + +**Session RPC followed by blob/data URLs.** Attachment images can use an asynchronous fetch, but this Markdown vocabulary must synchronously resolve a destination during a memoized render pass. + +**Image-only endpoints.** One file route can serve images, audio, and video without separate URL vocabularies. The current implementation returns complete bounded files; Markdown audio/video player nodes remain independent work. + +**Byte-signature validation in the route.** The model-facing `read_image` tool owns image admission checks. Display responses describe content by MIME lookup and let browser decoding reject corrupt payloads, avoiding a duplicate signature checker. + +**Workspace/media-only access (superseded).** The original policy restricted canonical paths to registered workspace roots and allowed image/video/audio MIME categories except SVG. Regular-file checks before opening rejected pipes and devices; an opened-handle identity comparison narrowed replacement races. These restrictions bounded authenticated access and avoided a per-request interactive authorization flow. They also excluded temporary screenshots and remote files; the successor note records the replacement policy and why those restrictions are not retained. + +## Consequences + +The Client vocabulary cannot bypass Host authentication or the filesystem provider. The original restricted route distinguished an existing outside-workspace path from an absent path, exposing existence even while refusing its bytes; the successor policy instead permits ordinary provider-readable files. + +Windows-style authored paths remain unsupported by the Client vocabulary. Trajectory and tool-card Markdown consumers do not supply this vocabulary, and audio/video Markdown nodes do not render players. These are renderer limitations, independent of the file route's readable MIME types. + +The archived [model-readable image paths](../../archived/feature/2026-08-21-model-readable-image-paths.md) note owns the model-facing behavior; this note owns user-facing display and does not supersede it. + +## Testing + +Renderer tests cover settled and streaming gates, reference-style images, protocol rechecks, failed-load fallback, and replacement sources. Chat tests cover the vocabulary and component wiring. The browser scenario in `apps/web/tests/markdown-images.e2e.ts` boots the shipped Web composition with a seeded Session and checks actual loading and fallback text. A model-driven recorded Session round trip remains separate from this UI expectation; the successor note names current route coverage. diff --git a/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md new file mode 100644 index 0000000000..de8a7f99d7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-07-session-prose-local-media-display.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 会话正文本地媒体路径通过同源文件路由显示 + +Status: implemented + +[English](2026-09-07-session-prose-local-media-display.md) | 中文 + +## Problem + +Assistant 正文可能通过文件系统路径引用图片,但浏览器无法读取 Host 文件。仅允许绝对 HTTP(S) 目标的渲染器会把这些引用保留为静态 alt 文本。Issue #3662 记录了这一展示缺口。 + +## Decision + +Session 正文中的本地媒体路径通过同源文件路由渲染。本记录拥有渲染器词表及其归属;[鉴权文件系统读取](2026-09-08-file-display-through-filesystem.zh.md)拥有当前文件服务策略,并取代下文的工作区与媒体限制。 + +`ui-primitives` 拥有 `MarkdownText` 上的 `MarkdownPathImages` 词表。与 `fileMentions` 一样,它只在消息稳定后生效,使冻结的流式块无法缓存词表处理函数。稳定渲染过程重写远程 URL 白名单之外的图片目标,并只输出绝对 `http(s)`、`blob` 或 `data` 结果。没有词表时,本地目标保留静态 alt 文本。加载失败会把图片替换为作者提供的 alt 文本;alt 为空时显示原始目标路径;不同来源仍可重新加载。 + +`ui-chat` 通过 `AssistantMarkdown` 提供页面稳定的 `localPathMediaUrl` 词表。它把绝对 POSIX 路径映射到页面同源的 `/api/file?path=…`。相对路径、协议相对路径、Windows 风格路径,以及 Electron `file://` 等非 HTTP 页面传输保持静态回退。 + +`session-controller` 在 `SessionFileReferences` 旁拥有 `SessionMediaReferences` 贡献。它通过 `connection.fetch` 注册;该通道执行与 `/api` RPC 相同的浏览器鉴权和信任检查。固定同源端点让同步渲染器获得稳定 URL,无需异步能力协商。 + +## Alternatives considered + +**由 Typert gateway 或 workspace controller 拥有。** gateway 拥有 Remote RPC 分发,workspace controller 拥有注册表生命周期。两者都不拥有文件字节展示;Session Controller 是服务 Session 正文的消费方。 + +**先经 Session RPC 获取,再使用 blob/data URL。** 附件图片可以异步获取,但此 Markdown 词表必须在记忆化渲染过程中同步解析目标。 + +**图片专用端点。** 单一文件路由即可服务图片、音频和视频,无需独立 URL 词表。当前实现返回有界完整文件;Markdown 音视频播放器节点仍是独立工作。 + +**路由中的字节签名校验。** 面向模型的 `read_image` 工具拥有图片准入检查。展示响应通过 MIME 查询描述内容,由浏览器解码拒绝损坏载荷,避免重复实现签名检查器。 + +**仅限工作区与媒体的访问(已取代)。** 原策略把规范路径限制在已注册工作区根目录内,并允许除 SVG 外的 image/video/audio MIME 类别。打开前的普通文件检查拒绝管道与设备;已打开句柄的身份比较收窄替换竞态。这些限制约束了鉴权后的访问范围,并避免每次请求的交互授权流程。它们也排除了临时截图与远程文件;后续记录说明替代策略及不保留这些限制的理由。 + +## Consequences + +客户端词表无法绕过 Host 鉴权或文件系统提供方。原受限路由区分了工作区外已存在路径与缺失路径,即使拒绝其字节仍暴露存在性;后续策略则允许提供方可读的普通文件。 + +客户端词表仍不支持作者提供的 Windows 风格路径。轨迹与工具卡片 Markdown 消费方不提供此词表,音视频 Markdown 节点也不渲染播放器。这些属于渲染器限制,与文件路由可读的 MIME 类型无关。 + +已归档的[模型可读图片路径](../../archived/feature/2026-08-21-model-readable-image-paths.md)记录拥有模型侧行为;本记录拥有用户侧展示,不取代它。 + +## Testing + +渲染器测试覆盖稳定与流式门禁、引用式图片、协议复查、加载失败回退和来源替换。聊天测试覆盖词表与组件连接。`apps/web/tests/markdown-images.e2e.ts` 浏览器场景使用已播种 Session 启动交付的 Web 组合,检查实际加载与回退文本。模型驱动的记录 Session 往返仍独立于此 UI 期望;后续记录说明当前路由覆盖。 diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml new file mode 100644 index 0000000000..0817dcd4f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md +2026-09-08-file-display-through-filesystem.md: b1f93f8c9fbd245ad69bc732b2a599c6e0abeb0d +2026-09-08-file-display-through-filesystem.zh.md: faa3fdb757c9a3fc60cae37692a559158e02ceee diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md new file mode 100644 index 0000000000..b1f93f8c9f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.md @@ -0,0 +1,35 @@ +# Agent Note: Authenticated file display reuses filesystem byte reads + +Status: implemented + +English | [中文](2026-09-08-file-display-through-filesystem.zh.md) + +## Problem + +Session prose can reference screenshots in temporary directories or files stored by a remote filesystem provider. A Host-local workspace allowlist cannot serve those paths. An image response without a byte limit can also make the browser download a 1 GiB image before attempting to decode it. + +## Decision + +The authenticated `/api/file` route reads ordinary files through `ctx.fs`. Authentication and the composed provider's read policy govern access; directory and MIME allowlists do not. This supersedes the serving policy in [the local-media display note](2026-09-07-session-prose-local-media-display.md), which retains renderer ownership and its rationale. + +GET calls the existing `readBytes(target, signal, maxBytes)`: providers reject known oversized files before content I/O and enforce the limit while reading. HEAD uses metadata without reading content. `FS_TOO_LARGE` becomes 413. MIME lookup supplies response metadata without sniffing file contents; unknown extensions use `application/octet-stream`. A sandbox CSP prevents directly opened HTML/SVG from executing with the authenticated API origin. + +All files use the resolved `ctx.attachments.imageLimits.maxImageBytes` limit, normally 20 MiB. The attachment service owns this deployment setting. All responses contain complete files; Range is ignored and no range support is advertised. + +## Alternatives considered + +**Workspace and media allowlists.** They limit which authenticated bytes can be read, but exclude ordinary screenshot locations and remote files. The chosen policy permits every regular file the composed provider can read. + +**A new filesystem byte-stream API.** Efficient large-file delivery and audio/video seeking would require implementations in every provider, including remote range handling. Complete bounded reads satisfy the current display scope without widening that interface. Streaming and Range can be added when those use cases justify the provider work. + +**Duplicate size checks in the route.** GET needs no additional stat/read loop: `readBytes` already owns preflight limits, growth detection, and cancellation. HEAD checks size separately because it must not read the body. + +## Consequences + +Temporary and remote files use the same filesystem provider as `read_image`, without adding model-facing events. The local sandbox provider constrains mutations and permits reads; an authenticated client therefore has broader access than registered workspace roots. Files remain subject to the provider's permissions and the route's byte limits. + +Each GET buffers the complete file in Host memory. Audio/video work as complete responses without incremental transfer or guaranteed seeking. Encoded byte limits do not bound decoded pixel dimensions. Failed image loads show authored alt text or the original destination when alt is empty. + +## Testing + +Route tests cover sparse 1 GiB rejection before content I/O, post-stat growth, the shared attachment byte limit, ordinary MIME types, temporary paths and symlinks, opaque remote targets, provider failures, metadata-only HEAD, ignored Range, and disposal. Browser expectations cover rendered images, 413/404 and corrupt-image fallbacks, and an image outside the workspace. Remote byte transfer remains owned by the existing filesystem provider tests. diff --git a/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md new file mode 100644 index 0000000000..faa3fdb757 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-08-file-display-through-filesystem.zh.md @@ -0,0 +1,35 @@ +# Agent Note: 鉴权文件展示复用文件系统字节读取 + +Status: implemented + +[English](2026-09-08-file-display-through-filesystem.md) | 中文 + +## Problem + +会话正文可能引用临时目录中的截图或远程文件系统提供方中的文件。Host 本地工作区白名单无法提供这些路径。没有字节上限的图片响应还可能让浏览器先下载一张 1 GiB 图片,再尝试解码。 + +## Decision + +鉴权 `/api/file` 路由通过 `ctx.fs` 读取普通文件。鉴权和所组合提供方的读取策略决定访问权限;目录与 MIME 白名单不参与准入。这取代了[本地媒体展示记录](2026-09-07-session-prose-local-media-display.zh.md)中的文件服务策略;该记录保留渲染器归属及其理由。 + +GET 调用现有 `readBytes(target, signal, maxBytes)`:提供方在内容 I/O 前拒绝已知超限文件,并在读取过程中执行上限。HEAD 使用元数据,不读取内容。`FS_TOO_LARGE` 转换为 413。MIME 查询提供响应元数据,不嗅探文件内容;未知扩展名使用 `application/octet-stream`。sandbox CSP 阻止直接打开的 HTML/SVG 以鉴权 API 源身份执行脚本。 + +所有文件均使用已解析的 `ctx.attachments.imageLimits.maxImageBytes` 上限,通常为 20 MiB。附件服务拥有此部署配置。所有响应均包含完整文件;忽略 Range,也不声明支持 Range。 + +## Alternatives considered + +**工作区和媒体白名单。** 它们限制鉴权后能读取哪些字节,却排除了常见截图位置和远程文件。所选策略允许读取所组合提供方可读的任意普通文件。 + +**新增文件系统字节流 API。** 高效的大文件传输和音视频跳转需要每个提供方实现,包括远端 Range 处理。有界完整读取满足当前展示范围,无需扩展该接口。相关用例足以支持这项提供方工作时,可以加入流式传输与 Range。 + +**在路由重复实现大小检查。** GET 无需额外的 stat/read 循环:`readBytes` 已经负责读取前上限、增长检测和取消。HEAD 单独检查大小,因为它不能读取正文。 + +## Consequences + +临时与远程文件使用与 `read_image` 相同的文件系统提供方,不增加模型可见事件。本地沙箱提供方约束变更操作并允许读取,因此鉴权客户端的访问范围大于已注册工作区根目录。文件仍受提供方权限和路由字节上限约束。 + +每个 GET 都会在 Host 内存中缓存完整文件。音视频使用完整响应,不支持增量传输,也不保证跳转播放。编码字节上限不限制解码后的像素尺寸。图片加载失败后展示作者提供的 alt 文本;alt 为空时展示原始目标路径。 + +## Testing + +路由测试覆盖内容 I/O 前拒绝稀疏 1 GiB 文件、stat 后增长、共用附件字节上限、普通 MIME 类型、临时路径与符号链接、不透明远程目标、提供方失败、仅元数据 HEAD、忽略 Range 和释放。浏览器期望覆盖图片渲染、413/404 及损坏图片回退,以及工作区之外的图片。远程字节传输仍由现有文件系统提供方测试负责。 diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml index 612d81556e..06f10621ec 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md -2026-07-04-doc-tiers-and-budgets.md: 378da8f8fddafa32dc7450bfac1c5376f2c7a065 -2026-07-04-doc-tiers-and-budgets.zh.md: 1d92ed7fbbec8a9a15bf94a2d320ee88f65a9fa8 +2026-07-04-doc-tiers-and-budgets.md: 209504218d18e97ae6da65bed9a22da40d2a7681 +2026-07-04-doc-tiers-and-budgets.zh.md: 9c866424d84b4fefa5ffe95efa21a3cf7d3c321a diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md index 378da8f8fd..209504218d 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.md @@ -13,14 +13,14 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - **Structure follows the documentation tree.** [docs/AGENTS.md](../../../../docs/AGENTS.md) is the documentation standard: a document owns detail about its subject, summarizes only the purpose, responsibility, and high-level behavior of direct children, and links to deeper owners. [Agent Notes](../../README.md) remain outside this structural contract. Every human-facing document is a tutorial with an ordered outcome or a reference with an explicit lookup scope; a [postmortem](../../../../docs/postmortem/README.md) is an incident-scoped reference whose chronology records evidence. Tutorials introduce concepts in prerequisite order for the reader's starting knowledge. - **A tier taxonomy with one home per fact.** The standard assigns every Markdown tier one job, forbids restating a fact outside its home tier, and carries the slop checklist used when writing or reviewing any doc. - **One product onboarding path.** The root README owns the recommended package-run path, the source-run alternative, and compact `dsh plugin --profile` usage. The published user guide starts with tasks inside the running Web UI, then links to distinct tutorials or reference owners for other interfaces, plugin development, and advanced configuration instead of repeating Web startup. -- **A narrow, hard budget gate.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and package READMEs are unbudgeted: length is legitimate there when every row is a fact, and review plus the slop checklist govern them. +- **Narrow, hard budget gates.** [scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) joins `doc-sync`: every doc listed in [scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) must stay under its word ceiling (`wc -w` semantics, whole file), and a budgeted file that is missing fails the gate so a rename cannot silently orphan its budget. Its scope is deliberately only the accretion-prone standing docs — the root and subtree `AGENTS.md` files, `architecture.md`, `packages/README.md`, and the standing policy docs they evict content into (`docs/testing.md`, `docs/defensive-patterns.md`). Reference docs, Agent Notes, and complete package READMEs remain unbudgeted because exhaustive facts can be long. The separate [package Summary gate](../../../../scripts/verify-package-readme-summaries.ts) caps only each English package entry paragraph at 100 words and directs failures to `dsh-doc` and the selected kind template. - **Ceilings are an enforcement frontier that ratchets.** A doc at or below its target keeps at least 5% headroom as its ceiling ratchets down; a doc above target keeps a frozen ceiling that prevents growth until it reaches the target (root `AGENTS.md` ≤ 1,600 words; `architecture.md` ≤ 1,800; subtree `AGENTS.md` ≤ 600 except `packages/AGENTS.md` ≤ 650 and `docs/AGENTS.md` ≤ 1,250; `packages/README.md` ≤ 600). When the gate goes red, relocate or condense; raise a ceiling only with explicit PR justification. - **A thin workflow skill, contracts in docs.** [.agents/skills/dsh-doc](../../../skills/dsh-doc/SKILL.md) carries the placement, audit, budget, and website workflow and defers to the standard as its source of truth, the same split as [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) over the i18n contract. ## Alternatives considered - **Skill and review discipline without a gate** — rejected: the accretion above happened while the current-state rule and reviewer attention already existed; a prose rule with no mechanical backstop demonstrably does not hold here, and this repo's own [quality-gates stance](2026-06-11-quality-gates.md) says invariants worth keeping are worth encoding. -- **A broad gate over every doc tier** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises. +- **A broad gate over every complete doc** — rejected: a blanket ceiling punishes exactly the right kind of long doc (a feature matrix or type catalog where every row is a fact) and generates per-file override churn that trains contributors to rubber-stamp raises. The package Summary limit instead bounds one common entry paragraph without constraining its owning reference sections. - **Independent onboarding tutorials for each documentation entry point** — rejected: duplicated setup steps drift in command order, first outcome, and product identity. A short README path followed by task-focused guides keeps the transition explicit without maintaining competing tutorials. - **Housing the standard inside the skill** — rejected: contracts live in docs and workflows in skills; a standard packed into SKILL.md is invisible to an agent that edits docs without invoking the skill, and `docs/AGENTS.md` already loads as subtree instructions for anyone working under `docs/`. @@ -30,4 +30,5 @@ Standing docs accumulated repeated rules, retold incidents, duplicated package m - Structural review starts with ownership and document form before sentence-level editing, so lower-level detail moves to its owner instead of being polished in the wrong place. - Readers reach a running Web UI before encountering headless execution, SDK embedding, custom profiles, or direct settings files; those interfaces remain available from their reference owners. - Budgeted docs that remain above target cannot grow; reaching the target restores the 5% working headroom. +- Package references retain exhaustive owned facts below their entry paragraph, while every package Summary stays within the same 100-word retrieval budget. - Word count is a crude proxy accepted deliberately: it cannot judge quality, but it forces the relocation decision at exactly the moment content is being added, which is when the author has the context to place it correctly. diff --git a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md index 1d92ed7fbb..9c866424d8 100644 --- a/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md +++ b/.agents/notes/implemented/process/2026-07-04-doc-tiers-and-budgets.zh.md @@ -13,14 +13,14 @@ Status: implemented - **结构遵循文档树。**[docs/AGENTS.md](../../../../docs/AGENTS.md) 是文档标准:文档负责承载其主题的详细内容,仅概述直接子项的目的、职责和高层行为,并链接到更深层内容的归属文档。[Agent Note](../../README.zh.md) 仍不受这一结构约定约束。每份面向人的文档要么是按顺序引导读者达成结果的教程(tutorial),要么是查阅范围明确的参考文档(reference);[事故复盘(postmortem)](../../../../docs/postmortem/README.zh.md) 是范围限定于单起事故的参考文档,其时间线记录证据。教程结合读者的起始知识,按前置依赖顺序介绍概念。 - **每项事实只归属一处的层级分类。**文档标准为每种 Markdown 层级分配单一职责,禁止在事实归属层级之外重复陈述,并包含编写或评审任何文档时使用的赘余检查清单。 - **单一产品入门路径。**根 README 负责推荐的包运行路径、从源码运行的备选路径和简要的 `dsh plugin --profile` 用法。已发布的用户指南从运行中的 Web UI 内部任务开始,再链接到其他界面的独立教程或插件开发与进阶配置的参考文档归属处,而不会重复介绍 Web 启动步骤。 -- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和包 README 不设预算:只要每一行都是事实,长度在这些位置就是合理的;评审和赘余检查清单负责约束它们。 +- **范围窄且严格的预算门禁。**[scripts/verify-doc-budgets.ts](../../../../scripts/verify-doc-budgets.ts) 接入 `doc-sync`:[scripts/doc-budgets.manifest.json](../../../../scripts/doc-budgets.manifest.json) 列出的每份文档都必须低于其词数上限(采用 `wc -w` 语义,统计整个文件);预算内文件缺失也会使门禁失败,使重命名无法悄然遗落其预算。该门禁的范围刻意只涵盖容易膨胀的常设文档——根目录和子树中的 `AGENTS.md` 文件、`architecture.md`、`packages/README.md`,以及它们将内容移入的常设策略文档(`docs/testing.md`、`docs/defensive-patterns.md`)。参考文档、Agent Note 和完整的包 README 仍不设预算,因为穷尽式事实可能很长。单独的[包 Summary 门禁](../../../../scripts/verify-package-readme-summaries.ts)只把每个英文包入口段落限制为 100 词,并引导失败项阅读 `dsh-doc` 和所选 kind 模板。 - **上限是只进不退的执行红线。** 达到或低于目标的文档在上限逐步下调时保留至少 5% 的余量;高于目标的文档则维持冻结的上限,在达到目标之前不得增长(根 `AGENTS.md` ≤ 1,600 词;`architecture.md` ≤ 1,800;子树 `AGENTS.md` ≤ 600,但 `packages/AGENTS.md` ≤ 650、`docs/AGENTS.md` ≤ 1,250;`packages/README.md` ≤ 600)。门禁变红时,迁移或压缩内容;只有在 PR(Pull Request)描述中给出明确理由时才提高上限。 - **精简的工作流 skill(技能),约定归文档。**[.agents/skills/dsh-doc](../../../skills/dsh-doc/SKILL.md) 承载文档放置、审计、预算与站点发布工作流,并以文档标准为真源,与 [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 和 i18n 约定之间的分工相同。 ## 曾考虑的替代方案 - **仅靠 skill 和评审纪律,不设门禁**:否决。上述膨胀正是在现行规则和评审注意力已经存在的情况下发生的;一条没有自动化保障的行文规则在此处已被证明无法维持,而本仓库自身的[质量门禁立场](2026-06-11-quality-gates.zh.md)认为值得保持的不变式就值得编码。 -- **对所有文档层级全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。 +- **对每份完整文档全面设限**:否决。一刀切的上限恰好惩罚了那些正当的长文档(如功能矩阵或类型目录,每一行都是事实),并产生逐文件的例外变更,训练贡献者机械地批准提限。包 Summary 上限只约束共同的入口段落,不限制其归属参考章节。 - **为每个文档入口维护独立入门教程**:否决。重复的设置步骤会在命令顺序、首个结果和产品定位上产生分歧。简短的 README 路径接上面向任务的指南,可明确衔接两者,且不需要维护相互竞争的教程。 - **将标准放在 skill 内部**:否决。约定归文档,工作流归 skill;如果标准被塞进 SKILL.md,那些不调用该 skill 而直接编辑文档的 agent(智能体)就看不到它,而 `docs/AGENTS.md` 已经作为子树指令被任何在 `docs/` 下工作的人加载。 @@ -30,4 +30,5 @@ Status: implemented - 结构评审先检查归属关系和文档形式,再进行句子层面的编辑,使较低层级的细节迁移到其归属文档,而不是在错误的位置加以润色。 - 读者会先进入可运行的 Web UI,再遇到 headless 执行、SDK 嵌入、自定义 profile 或直接 settings 文件;这些入口仍可从各自的参考文档归属处访问。 - 仍高于目标的受预算约束文档不得增长;达到目标后,将恢复 5% 的工作余量。 +- 包参考可在入口段落之后保留穷尽式归属事实,而每个包 Summary 都遵守相同的 100 词检索预算。 - 词数是一个粗糙的代理指标,这是有意接受的:它无法判断质量,但它在内容被添加的那一刻强制触发迁移决策,而那正是作者拥有足够上下文来正确放置内容的时刻。 diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml index 620dbfc5ad..8c6982cd71 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md -2026-08-08-unified-github-label-taxonomy.md: 625c5c1cac951bdc97187c17c964d677f31131c7 -2026-08-08-unified-github-label-taxonomy.zh.md: 855a2b98f44d517abe1f7718ae4e81262cb031b6 +2026-08-08-unified-github-label-taxonomy.md: 1748f9b77ed2922035c5e75ac4a2eee047f413d3 +2026-08-08-unified-github-label-taxonomy.zh.md: 3b221a5f3db43597656f46dfc505cdfda38c75db diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md index 625c5c1cac..1748f9b77e 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.md @@ -47,6 +47,8 @@ The area set is intentionally extensible. When no existing description honestly Issues use native Issue Type instead of `kind/*`; their `area/*` labels remain optional. `source/*` labels record how an Issue was created and do not apply to pull requests. Priority, GitHub defaults, and workflow triggers remain independent operational metadata. +The repository lifecycle removes pull request `kind/*` labels and reserved aliases from an Issue before auditing it. Policy comments report only violations whose intended value cannot be derived from the Issue, such as a missing native Type or an unsupported Priority. + Label migrations preserve meaning before removing aliases: add the canonical replacement, verify the labelable, then remove the obsolete assignment. A label is deleted only after no pull request or Issue still uses it, and unrelated labels are never replaced as a set. ## Alternatives considered @@ -65,8 +67,10 @@ Label migrations preserve meaning before removing aliases: add the canonical rep **Kinds on Issues.** Native Issue Type already owns that classification; duplicating it as a label creates drift. +**Comment-only Issue enforcement.** A comment preserves invalid metadata and requires human cleanup even when the only valid result is removal. The lifecycle applies that removal and retains comments for choices it cannot infer. + **Exactly one area per pull request.** Coherent changes can materially affect several independent APIs or behaviors, and dropping secondary areas hides affected scope. ## Consequences -Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. +Reviewers and automation can query intent, semantic scope, how an Issue was created, priority, and operational triggers independently. Invalid Issue labels disappear without a policy comment, and the label event records the repair; when no other violation remains, the lifecycle deletes any earlier policy comment. Maintainers must read the change and the live label descriptions instead of inferring classification from title prefixes or paths. The live catalog, this rationale, and policy enforcement must move together when a kind or a non-obvious area boundary changes, and taxonomy migrations carry an explicit historical backfill and verification cost. diff --git a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md index 855a2b98f4..3b221a5f3d 100644 --- a/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md +++ b/.agents/notes/implemented/process/2026-08-08-unified-github-label-taxonomy.zh.md @@ -47,6 +47,8 @@ Issue 已有原生 Issue Type 和独立的来源分类体系。在这两类对 Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然可选。`source/*` 标签记录 Issue 的创建方式,不适用于 PR。优先级、GitHub 默认标签和工作流触发器仍是相互独立的管理元数据。 +仓库生命周期会先从 Issue 中移除 PR `kind/*` 标签和保留别名,再执行审计。政策评论只报告无法从 Issue 推导预期值的违规项,例如缺失原生 Issue Type 或使用不受支持的优先级。 + 迁移标签时,须先保留语义,再移除别名:先添加规范替代标签,核验可加标签对象,再移除废弃的标签关系。只有在所有 PR 和 Issue 都不再使用某个标签后才能将其删除,且绝不整组替换无关标签。 ## 考虑过的替代方案 @@ -65,8 +67,10 @@ Issue 使用原生 Issue Type,而不是 `kind/*`;其 `area/*` 标签仍然 **在 Issue 上使用类型标签。** 原生 Issue Type 已负责这项分类;再用标签复制会造成漂移。 +**仅用评论执行 Issue 政策。** 评论会保留无效元数据;即使唯一有效结果是移除,仍要求人工清理。生命周期会直接执行这类移除,只对无法推断的选择保留评论。 + **每个 PR 恰好一个领域。** 内聚的变更可能对多个独立 API 或行为产生实质影响,丢弃次要领域会隐藏受影响范围。 ## 后果 -评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 +评审人和自动化流程可以分别查询意图、语义范围、Issue 的创建方式、优先级和工作流触发条件。无效的 Issue 标签会直接消失,不会产生政策评论;标签事件会记录该修复。如果不存在其他违规项,生命周期会删除更早的政策评论。维护者必须阅读变更内容和现行标签说明,而不能根据标题前缀或路径推断分类。当某种类型或某条非显然的领域边界发生变化时,现行标签清单、本记录中的决策依据和政策执行必须同步更新;分类体系迁移还会产生明确的历史回填和验证成本。 diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml new file mode 100644 index 0000000000..b32260c5e7 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md +2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5 +2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09 diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md new file mode 100644 index 0000000000..050905285b --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md @@ -0,0 +1,41 @@ +# Agent Note: Exclude documentation and comment-only changes from review routing + +Status: implemented + +English | [中文](2026-09-08-comment-only-review-routing.zh.md) + +## Problem + +Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes. + +GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks. + +## Decision + +Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule. + +Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical. + +The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change. + +The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes. + +## Verification + +[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded. + +## Alternatives considered + +**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes. + +**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal. + +**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict. + +**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches. + +## Consequences + +Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file. + +Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior. diff --git a/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md new file mode 100644 index 0000000000..b98f5d70b4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 从评审路由中排除文档和纯注释变更 + +Status: implemented + +[English](2026-09-08-comment-only-review-routing.md) | 中文 + +## 问题 + +只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。 + +GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。 + +## 决策 + +评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。 + +纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。 + +扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。 + +受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。 + +## 验证 + +[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。 + +## 考虑过的替代方案 + +**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。 + +**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。 + +**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。 + +**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。 + +## 后果 + +文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。 + +不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。 diff --git a/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md new file mode 100644 index 0000000000..b283624f39 --- /dev/null +++ b/.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md @@ -0,0 +1,55 @@ +# Agent Note: Route reviews from trusted changed-file policy + +Status: implemented + +## Problem + +GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision. + +Review routing needs an observable changed-file input, explicit owner rules, complete test exclusions, and a write-capable workflow that remains safe for pull requests from forks. + +## Decision + +The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS), outside GitHub's native CODEOWNERS locations. The map accepts only explicit absolute directory patterns with one or two individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, more than two owners, duplicate patterns, and duplicate owners. Later matching patterns replace earlier matches. + +The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase. + +The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets. + +The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them. + +The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.`, `.corpus.`, `.e2e.`, `.perf.`, `.snapshot.`, `.spec.`, `.stress.`, or `.test.`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions. + +The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, approved owners omitted from new requests, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it fetches the complete chronological review list and reduces each owner's undismissed `APPROVED` and `CHANGES_REQUESTED` reviews to the latest decisive state; `COMMENTED` and `PENDING` reviews leave that state unchanged. It removes the pull-request author, owners with an active approval, and users who remain requested from the matched individual owners. An active approval remains sufficient after later synchronize events, while a later changes-requested review makes the owner eligible again. The review-list operation fails before mutation at 3,000 entries or on an invalid record. + +The workflow keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores. + +When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation. + +## Verification + +[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, changed-LOC aggregation and ranking, complete pagination, file and review limits, approval-state reduction, approved-owner suppression and next-owner selection, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`. + +## Alternatives considered + +**Use native CODEOWNERS.** Native routing cannot ignore test-only changes and offers no repository-owned decision log before requesting reviewers. + +**Run under `pull_request` and check out the pull-request head.** A fork workflow does not receive a write-capable token, while granting a write token to code from an untrusted head is unsafe. + +**Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners. + +**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties. + +**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation. + +**Treat an empty current request as an owner who still needs review.** GitHub removes the pending request when the reviewer submits a review. Requesting an owner with an active approval again adds no ownership coverage and creates repeated notifications after later synchronize events. + +**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch. + +## Consequences + +Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, rule and changed-file updates remove obsolete workflow-authored requests on the next run, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself. + +The workflow requests at most one reviewer per run, does not repeat a request while that owner has an active approval, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger. + +Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file, review, or timeline API limit fail without applying a partial reviewer mutation. diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml index 18f7e242ba..5f57698abb 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md -2026-08-20-audience-first-documentation-quality.md: d44e9508959232397b90ad8a22a5e8b6040e0748 -2026-08-20-audience-first-documentation-quality.zh.md: 88c0c64266eed9a0744b43185362342638e4a6b9 +2026-08-20-audience-first-documentation-quality.md: 9e0c4a61408449100b79148a571cd740e4044040 +2026-08-20-audience-first-documentation-quality.zh.md: 0b6f1da54380f1d2d44afbe948131233def513cd diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md index d44e950895..9e0c4a6140 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.md @@ -51,7 +51,7 @@ Adopt one audience-first quality contract with five definitions: The [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) owns the first executable version of these rules. The `session-persistence-jsonl` README pair uses the shipped append, recovery, and encoding behavior as evidence rather than treating its prior prose as authority. - Every authored package README starts with searchable YAML. A Skill-style `description` and mechanically derived `kind` are required. Four kinds map one-to-one to four skill templates: `package-group` (group map), `package-reference` (plugin or service package), `package-library` (plain module entry), and `package-bundle` (`dsh.bundle.patch`). The counterpart path, hashes, and physical line alignment belong to the merge-safe sidecar and its gate, so README frontmatter contains no `i18n` block. The title or package manifest already owns the name, the document job expresses its audience, and tags remain absent until a governed taxonomy and search consumer proves value beyond full-text search. -- Authored pages start with a three-to-five-sentence `Summary`, then a linked `Table of Contents`. Format-owned Agent Notes, postmortems, generated fragments, and machine files keep their required skeletons. +- Authored pages start with a three-to-five-sentence `Summary`, then a linked `Table of Contents`. An English package README Summary stays within 100 `wc -w`-style words. It describes reader-visible capability instead of Cordis roles, registrations, or internal components, and omits source identifiers unless readers use them directly in configuration, commands, or a public API. Format-owned Agent Notes, postmortems, generated fragments, and machine files keep their required skeletons. - Each substantive section starts with a short orientation before subsections, tables, or code, and the page progresses from basic user use to advanced developer and maintainer detail. - English technical prose uses an ASD-STE100-inspired, non-certified clarity review: explicit actors and actions, stable terms, direct verbs, separated instructions and conditions, and preserved modality, exceptions, timing, and numbers. The 20-word instruction and 25-word description limits are review prompts. Precision overrides them. - Package contracts remain beside code. Cross-package material moves deliberately toward `docs/learn/overview/`, `docs/learn/cordis/`, `docs/learn/practices/`, `docs/user/`, `docs/developer/`, `docs/developer/discussion/`, `docs/scratch/`, and the parallel `docs/subsystems/` tier. @@ -87,11 +87,11 @@ The first prototype should use one large catalog and one mixed subsystem page. I 1. Create and validate `dsh-doc`, then rewrite one package README pair as a line-aligned, metadata-bearing prototype without changing runtime claims. 2. Review the rendered prototype with newcomer, user, developer, and agent tasks; revise the skill before enforcing the format elsewhere. -3. Add narrow metadata, section-order, line-alignment, link-resolution, and pairing fixtures. Keep sidecars until every merge and recovery consumer has replacement support. +3. Add narrow metadata, Summary-length, section-order, line-alignment, link-resolution, and pairing fixtures. Migrate every existing package Summary that violates the accepted entry limit, and keep sidecars until every merge and recovery consumer has replacement support. 4. Extract accepted standing rules into one canonical quality reference, condense `docs/AGENTS.md` below its target, and organize one coherent `docs/` topic at a time with atomic link/navigation repair. 5. Prototype generated-reference entry/detail separation on `config-catalog.md` and `docs/subsystems/core.md`; apply confirmed patterns elsewhere only after measured lookup cost falls without lost facts or route churn. -This sequence keeps each change independently reviewable. The first three slices improve criteria and correctness without rewriting the corpus; the generated-doc prototype supplies evidence before a broader information-architecture change. +This sequence keeps each change independently reviewable. The first three slices improve criteria and package entry points without changing the broader information architecture; the generated-doc prototype supplies evidence before a broader structural change. Slices 1–3 have shipped in this form: `dsh-doc` is the consolidated standard (`dsh-doc-standards` and `dsh-doc-site-sync` are folded into it, and the site workflow carries the corrected sidebar values), the `session-persistence-jsonl` README pair is the reference example, and `pnpm run test:docs` enforces the metadata, pairing, and quick documentation checks. Slices 4–5 remain open. @@ -107,7 +107,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i **Use readability scores as the quality gate.** Rejected because formulas penalize exact technical terms and cannot detect wrong ownership, missing failure behavior, stale commands, or a broken reader journey. -**Rewrite or split the full corpus immediately.** Rejected because the current system is mechanically healthy and many long references are appropriately exhaustive. A prototype should prove a retrieval improvement before route and translation churn spreads. +**Rewrite or split the full documentation corpus immediately.** Rejected because the current system is mechanically healthy and many long references are appropriately exhaustive. The bounded package-Summary migration does not alter routes or exhaustive reference content; larger structural changes still require measured evidence. **Keep the existing gates and rely on review for friendliness.** Rejected because the stale workflow values and budget-policy mismatch show that review alone does not preserve copied semantic claims, and the current gates do not ask whether a reader can complete a task. @@ -116,6 +116,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i - One canonical quality reference defines brief, intuitive, friendly, accurate, and agent-readable documentation by document job. - `.agents/skills/dsh-doc` validates and directly links its metadata, structure/hierarchy, and review/prototype references without duplicating their detailed rules in `SKILL.md`. - The `session-persistence-jsonl` README pair demonstrates searchable YAML, Summary, Table of Contents, user-to-developer progression, Further Exploration, final Dev Note, structural parity, and exact line-count equality while preserving verified package contracts. +- Every English package README Summary stays within 100 `wc -w`-style words; the focused gate reports the measured count and directs failures to `dsh-doc` and the selected kind template. - `docs/AGENTS.md` links that reference, remains sufficient as standing instruction, and is below its target with at least 5% headroom. - The root user path, Web quick start, first-plugin tutorial, contributor setup, and architecture overview each name an observable outcome and a verification owner without duplicating implementation detail. - The budget manifest records both target and temporary ceiling, and its check reports or rejects a violated headroom/ratchet state. @@ -128,7 +129,7 @@ This proposal does not shorten exhaustive facts, merge audience tiers, publish i ## Risks - Metadata can become boilerplate; the package README check therefore permits only fields with current retrieval, template-selection, or bilingual-consistency consumers. -- Hard sentence limits can fragment explanations or separate a condition from its consequence. The controlled-English word counts remain review prompts, and exact contracts override them. +- Hard sentence limits can fragment explanations or separate a condition from its consequence. The controlled-English sentence counts remain review prompts, while the separate 100-word package-Summary ceiling bounds only the entry paragraph and leaves exact contracts in the owning sections. - Exact line alignment can pressure translators into unnatural prose; review must protect meaning and may revise both sides together rather than weaken one. - Splitting generated references can increase routes and link maintenance; prototypes must preserve aliases and measure the trade-off. - A semantic check can become a repository-topology scanner that blocks legitimate changes; checks should cover high-risk copied values and representative journeys, while review owns prose meaning. diff --git a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md index 88c0c64266..0b6f1da543 100644 --- a/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md +++ b/.agents/notes/proposed/process/2026-08-20-audience-first-documentation-quality.zh.md @@ -51,7 +51,7 @@ Status: proposed [dsh-doc skill](../../../skills/dsh-doc/SKILL.md) 负责这些规则的首个可执行版本。`session-persistence-jsonl` README 对以已交付的追加、恢复与编码行为为证据,而不把其旧版正文当作权威。 - 每个撰写型包 README 都以可搜索 YAML 开头。Skill 风格的 `description` 与按机制推导的 `kind` 为必填字段。四种 kind 与四个技能模板一一对应:`package-group`(组地图)、`package-reference`(插件或服务包)、`package-library`(纯模块入口)与 `package-bundle`(`dsh.bundle.patch`)。对照文件路径、哈希与物理行对齐由支持自动合并的 sidecar 及其门禁负责,因此 README frontmatter 不包含 `i18n` 块。名称已由标题或包 manifest 归属,受众已由文档职责表达;在受治理的标签分类与搜索消费方证明其价值超过全文检索之前,不加入标签。 -- 撰写型页面先写三至五句的 `Summary`,再写带链接的 `Table of Contents`。由格式约束的 Agent Note、事故复盘、生成片段和机器文件保留其必需骨架。 +- 撰写型页面先写三至五句的 `Summary`,再写带链接的 `Table of Contents`。英文包 README 的 Summary 不超过 100 个按 `wc -w` 语义统计的词。它描述读者可见能力,而不是 Cordis 角色、注册项或内部组件;除非读者会在配置、命令或公开 API 中直接使用某个源码标识符,否则不得写入该标识符。由格式约束的 Agent Note、事故复盘、生成片段和机器文件保留其必需骨架。 - 每个实质章节在子章节、表格或代码之前先给出简短引导,页面则从基础用户用法逐步进入高级开发者与维护者细节。 - 英文技术正文采用受 ASD-STE100 启发但不宣称认证的清晰度评审:明确行动者与动作,稳定使用术语,使用直接动词,拆分指令与条件,并完整保留情态、例外、时序与数值。指令 20 词和描述 25 词的限制仅作评审提示。准确性高于句长。 - 包约定留在代码旁。跨包材料有计划地向 `docs/learn/overview/`、`docs/learn/cordis/`、`docs/learn/practices/`、`docs/user/`、`docs/developer/`、`docs/developer/discussion/`、`docs/scratch/` 和平行的 `docs/subsystems/` 层级迁移。 @@ -87,11 +87,11 @@ Status: proposed 1. 创建并验证 `dsh-doc`,再把一组 package README 对改写为行对齐、带元数据的原型,同时不改变运行时事实。 2. 用新人、用户、开发者和 agent 任务评审渲染后的原型;先修订 skill,再在其他位置强制执行该格式。 -3. 添加聚焦的元数据、章节顺序、行对齐、链接解析和配对 fixture。在每个合并与恢复消费方都有替代支持前,保留伴随文件。 +3. 添加聚焦的元数据、Summary 长度、章节顺序、行对齐、链接解析和配对 fixture。迁移所有违反已接受入口上限的既有包 Summary;在每个合并与恢复消费方都有替代支持前,保留伴随文件。 4. 把已接受的常驻规则提取到一份规范质量参考,将 `docs/AGENTS.md` 精简到目标以下,并且一次只组织一个内聚的 `docs/` 主题,同时原子地修复链接与导航。 5. 在 `config-catalog.md` 和 `docs/subsystems/core.md` 上制作生成参考入口层与细节层分离的原型;只有实测查询成本下降且没有丢失事实或造成路由扰动,才把确认后的模式应用到其他位置。 -该顺序使每项变更都能独立评审。前三个切片在不重写语料的情况下改进标准与正确性;生成文档原型则在更广的信息架构变更前提供证据。 +该顺序使每项变更都能独立评审。前三个切片改进标准与包入口,而不改变更广的信息架构;生成文档原型则在更广的结构变更前提供证据。 切片 1–3 已按此形式交付:`dsh-doc` 成为合并后的标准(`dsh-doc-standards` 与 `dsh-doc-site-sync` 已并入其中,站点工作流携带修正后的侧边栏值),`session-persistence-jsonl` README 对是参考示例,`pnpm run test:docs` 强制执行元数据、配对与快速文档检查。切片 4–5 仍待完成。 @@ -107,7 +107,7 @@ Status: proposed **把可读性分数作为质量门禁。**不予采纳,因为公式会惩罚精确技术术语,却无法发现错误所有权、遗漏失败行为、陈旧命令或破损的读者路径。 -**立即重写或拆分全部语料。**不予采纳,因为现有系统在机制上健康,许多长参考也确实应保持穷尽。原型应先证明检索有所改善,再扩散路由和翻译扰动。 +**立即重写或拆分全部文档语料。**不予采纳,因为现有系统在机制上健康,许多长参考也确实应保持穷尽。范围受限的包 Summary 迁移不会改变路由或穷尽式参考内容;更大的结构变更仍需实测证据。 **保留现有门禁,让评审负责友好程度。**不予采纳,因为陈旧工作流值和预算策略不一致说明,仅凭评审无法保留复制的语义事实,而现有门禁也不询问读者是否能完成任务。 @@ -116,6 +116,7 @@ Status: proposed - 一份规范质量参考按文档职责定义简短、直观、友好、准确和便于 agent 阅读的文档。 - `.agents/skills/dsh-doc` 通过验证,并直接链接其元数据、结构或层级及评审或原型参考,而不在 `SKILL.md` 中复制这些参考的详细规则。 - `session-persistence-jsonl` README 对展示可搜索 YAML、Summary、Table of Contents、从用户到开发者的渐进结构、Further Exploration、结尾 Dev Note、结构一致性和精确行数相等,同时保留已验证的包约定。 +- 每个英文包 README Summary 都不超过 100 个按 `wc -w` 语义统计的词;聚焦门禁报告实测词数,并引导失败项阅读 `dsh-doc` 与所选 kind 模板。 - `docs/AGENTS.md` 链接该参考,仍足以充当常驻指令,并低于其目标且至少保留 5% 余量。 - 根级用户路径、Web 快速开始、第一个插件教程、贡献者设置和架构概览各自给出一个可观察结果与验证归属者,同时不复制实现细节。 - 预算 manifest 同时记录目标与临时上限,其检查会报告或拒绝违反余量或棘轮规则的状态。 @@ -128,7 +129,7 @@ Status: proposed ## 风险 - 元数据可能沦为样板;因此包 README 检查只允许具有现行检索、模板选择或双语一致性消费方的字段。 -- 硬性句长限制可能割裂说明,或把条件与后果分开。受控英语的词数限制仅作评审提示,精确约定优先于句长。 +- 硬性句长限制可能割裂说明,或把条件与后果分开。受控英语的句长仅作评审提示;单独的 100 词包 Summary 上限只约束入口段落,精确约定仍保留在其归属章节。 - 精确行对齐可能迫使译者写出不自然的正文;评审必须保护含义,并可同时修订两侧,而不是削弱其中一侧。 - 拆分生成参考可能增加路由与链接维护;原型必须保留别名并衡量取舍。 - 语义检查可能膨胀成阻塞正当变更的仓库拓扑扫描器;检查应覆盖高风险复制值和代表性路径,而正文含义仍由评审负责。 diff --git a/.agents/skills/dsh-doc/SKILL.md b/.agents/skills/dsh-doc/SKILL.md index 87e6e6b3f1..96e2b60a8b 100644 --- a/.agents/skills/dsh-doc/SKILL.md +++ b/.agents/skills/dsh-doc/SKILL.md @@ -61,7 +61,7 @@ Open the template before writing and follow its skeleton and rules; it states wh These rules decide what a section may say. They apply to every authored human-facing page, and to package READMEs with particular force. -- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. +- **Summary says what the subject does.** The opening `Summary` and the user-facing sections describe what a user or agent can DO with the subject — outcomes, benefits, when to choose it, main cost — never its role, type, or internal identity. In a package Summary, “what it is” means only its reader-visible capability, not its Cordis role, registrations, or internal components. Omit source identifiers unless the reader directly uses them in configuration, a command, or a public API. "The seam registers `ctx.x` and appends `x/event` records" is identity narration; "you can save a note per message and it survives restarts" is what it does. - **Developer sections explain, never enumerate.** Folded implementation content covers the overall design concept, architecture, and hand-waving dataflow — enough to understand how the package works — and links code for exact detail. No full API catalogs, exhaustive column lists, event-payload enumerations, or JSDoc restatement inside the folds. - **Dev Note is the only slop zone.** Partial ideas, scratches, undecided directions, measured artifacts, and working hypotheses live only in the final Dev Note, marked explicitly non-authoritative. Every other section is polished, current-state prose. - **Current state only.** No compatibility shims, migration talk, or history ("previously", "now", "no longer", renamed) outside the Dev Note; the codebase as it is today is the only subject. @@ -118,7 +118,7 @@ Validate the affected format, not merely Markdown syntax. A strong promise needs - Bilingual pages: verify structure, exact line count, terminology, link parity, and the sidecar record. - Tutorials: exercise the documented entry path or name an explicit manual verification owner. - Generated references: run the deterministic freshness check and report retrieval-size measures. -- Package READMEs: run model-experience and limitation checks, then package-focused tests when behavior claims changed; re-run every command the README instructs before merging a claim about it. +- Package READMEs: run the Summary gate, which limits each English Summary to 100 `wc -w`-style words and directs failures back to this skill and the kind template; run model-experience and limitation checks, then package-focused tests when behavior claims changed; re-run every command the README instructs before merging a claim about it. - Skills: run the repository's skill-invocation metadata check. Run `pnpm run test:docs` for the quick comprehensive documentation checks (pairing, wrap, links, README gates, budgets, skill metadata, Agent Note gates) before the full `pnpm run doc-sync`. diff --git a/.agents/skills/dsh-doc/references/review.md b/.agents/skills/dsh-doc/references/review.md index 94b24fb770..b1014c04f9 100644 --- a/.agents/skills/dsh-doc/references/review.md +++ b/.agents/skills/dsh-doc/references/review.md @@ -32,7 +32,7 @@ Retain a statement only when it helps the target reader act, reason, or avoid mi Require the following without forcing one universal internal heading set: - searchable YAML metadata with a precise `description` and the mechanically derived `kind` (`package-group`, `package-reference`, `package-library`, or `package-bundle`); -- a three-to-five-sentence Summary that says what the subject DOES for its user or agent reader, with a linked Table of Contents; +- a three-to-five-sentence English Summary of at most 100 `wc -w`-style words that says what the subject DOES for its user or agent reader, with a linked Table of Contents; - controlled English with explicit actors, stable terms, direct verbs, separated instructions and conditions, and unchanged modality; - when to choose or avoid the package; - a smallest safe configuration or usage path when one exists — for a bundle, the verified `dsh plugin` install path; for a library, the consumer entry point; never profile-install guidance for a shape that does not take it; @@ -42,7 +42,7 @@ Require the following without forcing one universal internal heading set: - newcomer-facing Further Exploration where adjacent docs materially help; - a final non-authoritative Dev Note as the only home for partial ideas, scratches, and undecided directions. -Do not restate JSDoc or generated catalogs. Link the owner and explain only the decision or relationship needed locally. Reject any user-facing section that narrates internals (function subjects, event streams, data flow) and any fold that enumerates APIs instead of explaining the concept. +Do not restate JSDoc or generated catalogs. Link the owner and explain only the decision or relationship needed locally. A package Summary describes reader-visible capability rather than Cordis roles, registrations, or internal components, and it omits source identifiers unless readers directly use them in configuration, commands, or a public API. Reject any user-facing section that narrates internals (function subjects, event streams, data flow) and any fold that enumerates APIs instead of explaining the concept. ## Reference example diff --git a/.agents/skills/dsh-doc/references/structure-hierarchy.md b/.agents/skills/dsh-doc/references/structure-hierarchy.md index 0f4935f9fa..6586c815a5 100644 --- a/.agents/skills/dsh-doc/references/structure-hierarchy.md +++ b/.agents/skills/dsh-doc/references/structure-hierarchy.md @@ -21,7 +21,7 @@ Use this order for authored human-facing pages when the format owner permits it. 1. YAML metadata. 2. H1 title. 3. Language switcher for a bilingual page. -4. `## Summary`: three to five explanatory sentences stating what the subject is, why a reader would care, the main operating model, and the most important boundary. +4. `## Summary`: three to five explanatory sentences stating what the reader can do or observe, why a reader would care, the main operating model, and the most important boundary. English package README Summaries stay within the gate-owned 100-word limit. 5. `## Table of Contents`: links to the page's H2 sections; keep it navigational rather than descriptive. 6. Stable content, ordered from user-facing use to developer-facing design and operational detail. 7. Optional `## Further Exploration` for newcomer-oriented links to adjacent subjects. diff --git a/.agents/skills/dsh-doc/references/style.md b/.agents/skills/dsh-doc/references/style.md index 9a974fda0d..f05f06ef49 100644 --- a/.agents/skills/dsh-doc/references/style.md +++ b/.agents/skills/dsh-doc/references/style.md @@ -15,7 +15,7 @@ Page-level style preferences that make DSH pages scannable and difficult to misr ## Short summary -Open every authored page with a short `Summary`: three to five sentences in one paragraph stating what the subject is, why the reader cares, the operating model, and the most important boundary. The Table of Contents and the sections carry the detail; placement and section order live in [structure-hierarchy.md](structure-hierarchy.md). +Open every authored page with a short `Summary`: three to five sentences in one paragraph stating what the reader can do or observe, why the reader cares, the operating model, and the most important boundary. The Table of Contents and the sections carry the detail; placement and section order live in [structure-hierarchy.md](structure-hierarchy.md). An English package README Summary is additionally limited to 100 `wc -w`-style words by `verify-package-readme-summaries`. ## Controlled technical English diff --git a/.agents/skills/dsh-doc/templates/package-bundle.md b/.agents/skills/dsh-doc/templates/package-bundle.md index 634d6347b0..59db5b0e30 100644 --- a/.agents/skills/dsh-doc/templates/package-bundle.md +++ b/.agents/skills/dsh-doc/templates/package-bundle.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what a profile gains from this layer, which profiles already include it, how a user adds or removes it, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what a profile gains from this layer, which profiles already include it, how a user adds or removes it, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-group.md b/.agents/skills/dsh-doc/templates/package-group.md index 7a9549de76..2e1fe14139 100644 --- a/.agents/skills/dsh-doc/templates/package-group.md +++ b/.agents/skills/dsh-doc/templates/package-group.md @@ -20,7 +20,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what the family provides, what a reader can DO with it, which package owns which half, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what the family provides, what a reader can DO with it, which package owns which half, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-library.md b/.agents/skills/dsh-doc/templates/package-library.md index fcd37f17b0..f576634f9c 100644 --- a/.agents/skills/dsh-doc/templates/package-library.md +++ b/.agents/skills/dsh-doc/templates/package-library.md @@ -22,7 +22,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences: what a caller can DO with the library, who consumes it, the smallest entry point, and the main boundary. +Three to five sentences and at most 100 `wc -w`-style words: what a caller can DO with the library, who consumes it, the smallest entry point, and the main boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules). ## Table of Contents diff --git a/.agents/skills/dsh-doc/templates/package-reference.md b/.agents/skills/dsh-doc/templates/package-reference.md index 5fda732779..499966301c 100644 --- a/.agents/skills/dsh-doc/templates/package-reference.md +++ b/.agents/skills/dsh-doc/templates/package-reference.md @@ -20,7 +20,7 @@ English | [中文](README.zh.md) ## Summary -Three to five sentences on what a user or agent can DO with the package: outcomes, when to choose it, main cost, most important boundary. Never its role, type, or internal identity. +Three to five sentences and at most 100 `wc -w`-style words on what a user or agent can DO with the package: outcomes, when to choose it, main cost, most important boundary. Apply the [Summary voice rules](../SKILL.md#voice-rules); never describe its role, type, or internal identity. ## Table of Contents diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 262851e4e2..c9793f14b3 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -218,9 +218,7 @@ export function retainIssueReferences(references, issues) { export function validateIssue(issue) { const errors = [] const status = issue.status - const invalidLabels = issue.labels.filter( - (label) => label.startsWith('kind/') || LEGACY_LABELS.has(label), - ) + const invalidLabels = issue.labels.filter(isInvalidIssueLabel) if (invalidLabels.length > 0) { errors.push(`Issue 不得使用 PR kind 或旧版标签:${invalidLabels.join(', ')}`) @@ -245,6 +243,10 @@ export function validateIssue(issue) { return errors } +function isInvalidIssueLabel(label) { + return label.startsWith('kind/') || LEGACY_LABELS.has(label) +} + /** * Validate PR metadata and its referenced Issues. * @param {{authorType: string, labels: string[], references: ReturnType, issues: Map}} input PR snapshot. @@ -312,8 +314,9 @@ function projectToken() { } async function api(path, options = {}) { + const { allow404 = false, ...requestOptions } = options const response = await fetch(`${process.env.GITHUB_API_URL ?? 'https://api.github.com'}${path}`, { - ...options, + ...requestOptions, headers: { Accept: 'application/vnd.github+json', Authorization: `Bearer ${token()}`, @@ -322,10 +325,10 @@ async function api(path, options = {}) { ...options.headers, }, }) - if (options.allow404 && response.status === 404) return null + if (allow404 && response.status === 404) return null if (!response.ok) { const body = await response.text() - throw new Error(`${options.method ?? 'GET'} ${path}: ${response.status} ${body}`) + throw new Error(`${requestOptions.method ?? 'GET'} ${path}: ${response.status} ${body}`) } if (response.status === 204) return null return response.json() @@ -573,6 +576,25 @@ async function setStatus(number, status) { await updateStatus(await ensureProjectItem(number), status) } +/** + * Remove pull-request kinds and retired aliases from one Issue snapshot. + * @param {{number: number, labels: string[]}} issue Issue snapshot. + * @returns {Promise} Snapshot containing only labels that remain on the Issue. + */ +export async function repairIssueLabels(issue) { + const invalidLabels = issue.labels.filter(isInvalidIssueLabel) + for (const label of invalidLabels) { + await api( + `/repos/${config.organization}/${config.repository}/issues/${issue.number}/labels/${encodeURIComponent(label)}`, + { method: 'DELETE', allow404: true }, + ) + } + return { + ...issue, + labels: issue.labels.filter((label) => !isInvalidIssueLabel(label)), + } +} + async function upsertAudit(number, errors) { const comments = await api( `/repos/${config.organization}/${config.repository}/issues/${number}/comments?per_page=100`, @@ -605,10 +627,18 @@ async function upsertAudit(number, errors) { } } -async function auditIssue(number, extraErrors = [], status = undefined) { +/** + * Repair deterministic Issue metadata violations and publish the remaining audit result. + * @param {number} number Same-repository Issue number. + * @param {string[]} extraErrors Errors supplied by the triggering lifecycle operation. + * @param {string|null|undefined} status Optional known Project status. + * @returns {Promise} Violations that remain after repair. + */ +export async function auditIssue(number, extraErrors = [], status = undefined) { const issue = await issueSnapshot(number, status) if (!issue) return [] - const errors = [...extraErrors, ...validateIssue(issue)] + const repairedIssue = await repairIssueLabels(issue) + const errors = [...extraErrors, ...validateIssue(repairedIssue)] await upsertAudit(number, errors) return errors } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index b39ab0491e..7ee2a85dc2 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -3,12 +3,14 @@ import { readFileSync, readdirSync } from 'node:fs' import test from 'node:test' import { + auditIssue, initializeIssueStartDate, initializePullRequestStartDates, issueSnapshot, nextResolvingIssueStatus, parseReferences, projectDate, + repairIssueLabels, retainIssueReferences, resolvingIssueStatusCommand, requiresPullRequestPolicy, @@ -248,6 +250,101 @@ test('reserves PR kind and legacy labels for pull requests', () => { assert.deepEqual(validateIssue({ ...legalIssue, labels: ['area/web', 'source/member'] }), []) }) +test('removes reserved labels from Issues before validation', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, method: options.method }) + assert.equal(options.headers.Authorization, 'Bearer test-token') + if (url.endsWith('/labels/bug-fix')) { + return Response.json({ message: 'Label does not exist' }, { status: 404 }) + } + return Response.json([]) + }) + + const issue = { + ...legalIssue, + number: 42, + labels: ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member'], + } + const repaired = await repairIssueLabels(issue) + + assert.deepEqual(repaired.labels, ['area/web', 'source/member']) + assert.deepEqual(issue.labels, ['area/web', 'kind/bug-fix', 'bug-fix', 'source/member']) + assert.deepEqual(validateIssue(repaired), []) + assert.deepEqual(requests, [ + { + url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix', + method: 'DELETE', + }, + { + url: 'https://api.github.com/repos/deepseek-harness/deepseek-harness/issues/42/labels/bug-fix', + method: 'DELETE', + }, + ]) +}) + +test('deletes a stale audit comment after repairing its only violation', async (t) => { + const previousToken = process.env.GH_TOKEN + process.env.GH_TOKEN = 'test-token' + t.after(() => { + if (previousToken === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = previousToken + }) + const requests = [] + t.mock.method(globalThis, 'fetch', async (url, options) => { + requests.push({ url, method: options.method ?? 'GET' }) + if (url.endsWith('/issues/42')) { + return Response.json({ + node_id: 'issue-id', + labels: [{ name: 'area/web' }, { name: 'kind/bug-fix' }], + type: { name: 'Bug' }, + state: 'open', + state_reason: null, + }) + } + if (url.endsWith('/graphql')) return Response.json({ data: projectGraphqlData() }) + if (url.endsWith('/labels/kind%2Fbug-fix')) return Response.json([{ name: 'area/web' }]) + if (url.endsWith('/issues/42/comments?per_page=100')) { + return Response.json([ + { + id: 99, + user: { type: 'Bot' }, + body: '\nold audit', + }, + ]) + } + if (url.endsWith('/issues/comments/99')) return new Response(null, { status: 204 }) + return Response.json({ message: 'unexpected request' }, { status: 500 }) + }) + + assert.deepEqual(await auditIssue(42), []) + assert.deepEqual( + requests.map(({ url, method }) => ({ path: new URL(url).pathname + new URL(url).search, method })), + [ + { path: '/repos/deepseek-harness/deepseek-harness/issues/42', method: 'GET' }, + { path: '/graphql', method: 'POST' }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/42/labels/kind%2Fbug-fix', + method: 'DELETE', + }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/42/comments?per_page=100', + method: 'GET', + }, + { + path: '/repos/deepseek-harness/deepseek-harness/issues/comments/99', + method: 'DELETE', + }, + ], + ) +}) + test('keeps terminal Status aligned with the native close reason', () => { assert.deepEqual( validateIssue({ ...legalIssue, status: 'Done', state: 'closed', stateReason: 'completed' }), diff --git a/.github/review-ownership/CODEOWNERS b/.github/review-ownership/CODEOWNERS new file mode 100644 index 0000000000..eccc7e6844 --- /dev/null +++ b/.github/review-ownership/CODEOWNERS @@ -0,0 +1,59 @@ +# Custom static-scanner input. Its nested path keeps GitHub from loading it as +# the repository's native CODEOWNERS file. +/apps/cli/ @turtle1999 +/apps/web/ @imccyu +/docs/ @turtle1999 +/native/ @mektpoy +/patches/ @mektpoy +/python/ @LegGasai +/vendor/ @turtle1999 +/website/ @LegGasai +/packages/acp/ @mektpoy +/packages/api/ @imccyu +/packages/attachment/ @CreatixChu +/packages/boot/ @turtle1999 +/packages/bundle/ @turtle1999 +/packages/client/ @imccyu +/packages/code-runtime/ @Chinesezjc +/packages/compaction/ @imccyu +/packages/context/ @turtle1999 +/packages/core/ @turtle1999 @mektpoy +/packages/credentials/ @mektpoy +/packages/e2b/ @mektpoy +/packages/experimental/ @mektpoy +/packages/extensions/ @mektpoy +/packages/feedback/ @mektpoy +/packages/fs/ @mektpoy +/packages/goal/ @mektpoy +/packages/guard/ @turtle1999 +/packages/hooks/ @mektpoy +/packages/host/ @turtle1999 +/packages/identity/ @imccyu +/packages/interaction/ @imccyu +/packages/jobs/ @imccyu +/packages/llm/ @LegGasai +/packages/lsp/ @mektpoy +/packages/mcp/ @mektpoy +/packages/plan/ @mektpoy +/packages/preset/ @LegGasai @turtle1999 +/packages/runtime-diagnostics/ @mektpoy +/packages/sandbox/ @mektpoy +/packages/schedule/ @imccyu +/packages/sdk/ @mektpoy +/packages/session/ @turtle1999 @mektpoy +/packages/session-query/ @mektpoy +/packages/settings/ @mektpoy +/packages/shell/ @mektpoy +/packages/skill/ @mektpoy +/packages/spill/ @mektpoy +/packages/storage/ @imccyu +/packages/subagent/ @Dudu-0223 +/packages/subprocess/ @mektpoy +/packages/terminal/ @imccyu +/packages/todo/ @mektpoy +/packages/typert/ @imccyu +/packages/util/ @mektpoy +/packages/web/ @imccyu +/packages/webhook/ @mektpoy +/packages/workflow/ @mektpoy +/packages/workspace/ @imccyu diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md new file mode 100644 index 0000000000..f6f232c895 --- /dev/null +++ b/.github/review-ownership/README.md @@ -0,0 +1,61 @@ +# Automated review requests + +## Summary + +The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly. + +## Table of Contents + +- [Routing](#routing) +- [Review exclusions](#review-exclusions) +- [Security](#security) +- [Verification](#verification) +- [Dev Note](#dev-note) + + + +## Routing + +Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API. + +For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties. + +Before selecting a new reviewer, a non-draft run fetches the pull request's complete chronological review list. An owner's latest undismissed decisive review is `APPROVED` or `CHANGES_REQUESTED`; comments and pending reviews do not replace that decision. An approved owner remains omitted after later synchronize events, while a later changes-requested review makes the owner eligible again. The workflow fails before mutation when the list reaches the supported 3,000-review limit or contains an invalid record. + +On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events. + +The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches and LOC; the aggregate owner relevance ranking; approved owners omitted from new requests; current individual requests and the available counted slot after planned cancellations; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author, approved owners, and users who remain requested are omitted from new requests. + +The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase. + + + +## Review exclusions + +Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files. + +Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name. + +Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies. + +For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable. + + + +## Security + +The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs. + +Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run. + + + +## Verification + +Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI. + + + +## Dev Note + +The [review-routing decision](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) records the security model, test exclusions, and alternatives. diff --git a/.github/review-ownership/request-review.mjs b/.github/review-ownership/request-review.mjs new file mode 100644 index 0000000000..c2d1ee7f43 --- /dev/null +++ b/.github/review-ownership/request-review.mjs @@ -0,0 +1,660 @@ +#!/usr/bin/env node + +import { readFileSync } from 'node:fs' +import process from 'node:process' +import { pathToFileURL } from 'node:url' + +const API_VERSION = '2026-03-10' +const MAX_OWNERS_PER_RULE = 2 +const MAX_PULL_REQUEST_FILES = 3_000 +const MAX_PULL_REQUEST_REVIEWS = 3_000 +const MAX_COUNTED_REQUESTED_REVIEWERS = 1 +const MAX_TIMELINE_EVENTS = 3_000 +const PAGE_SIZE = 100 +const PULL_REQUEST_REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) +const UNCOUNTED_REVIEWER = 'turtle1999' +const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]' +const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests']) +const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u +const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u +const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu +const C_STYLE_EXTENSIONS = new Set([ + 'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx', + 'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx', +]) +const BLOCK_COMMENT_EXTENSIONS = new Set(['css']) +const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh']) +const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html']) + +/** + * Parse the explicit directory subset accepted from the review ownership file. + * @param {string} source CODEOWNERS-compatible source text. + * @returns {Array<{pattern: string, prefix: string, owners: string[]}>} Ordered ownership rules. + */ +export function parseOwnership(source) { + const rules = [] + const patterns = new Set() + for (const [index, rawLine] of source.split('\n').entries()) { + const line = rawLine.trim() + if (!line || line.startsWith('#')) continue + const [pattern, ...owners] = line.split(/\s+/u) + const location = `ownership line ${index + 1}` + if (!/^\/[^*?[\]#!\\]+\/$/u.test(pattern)) { + throw new Error(`${location}: expected one explicit absolute directory pattern`) + } + if (pattern.startsWith('/.')) throw new Error(`${location}: hidden-directory patterns are not allowed`) + if (patterns.has(pattern)) throw new Error(`${location}: duplicate pattern ${JSON.stringify(pattern)}`) + if (owners.length === 0) throw new Error(`${location}: expected at least one owner`) + if (owners.length > MAX_OWNERS_PER_RULE) { + throw new Error(`${location}: expected at most ${MAX_OWNERS_PER_RULE} owners`) + } + const normalizedOwners = [] + const seenOwners = new Set() + for (const owner of owners) { + if (!/^@[A-Za-z0-9-]+$/u.test(owner)) { + throw new Error(`${location}: only individual GitHub users are supported`) + } + const key = owner.toLowerCase() + if (seenOwners.has(key)) throw new Error(`${location}: duplicate owner ${owner}`) + seenOwners.add(key) + normalizedOwners.push(owner) + } + patterns.add(pattern) + rules.push({ pattern, prefix: pattern.slice(1), owners: normalizedOwners }) + } + if (rules.length === 0) throw new Error('ownership file contains no rules') + return rules +} + +/** + * Normalize a repository-relative path received from GitHub. + * @param {unknown} value GitHub file path. + * @returns {string} Slash-normalized repository path. + */ +export function normalizeRepositoryPath(value) { + if (typeof value !== 'string' || value.length === 0) throw new Error('changed file has no path') + const normalized = value.replaceAll('\\', '/').replace(/^\.\/+/, '') + if ( + normalized.startsWith('/') + || normalized.includes('\0') + || normalized.split('/').some(segment => !segment || segment === '.' || segment === '..') + ) { + throw new Error(`invalid repository path ${JSON.stringify(value)}`) + } + return normalized +} + +/** + * Decide whether a repository path belongs only to test evidence or test support. + * @param {string} value Repository-relative path. + * @returns {boolean} Whether reviewer routing must ignore the path. + */ +export function isTestPath(value) { + const file = normalizeRepositoryPath(value) + const segments = file.split('/') + if (segments[0] === 'benchmarks' || segments[0] === 'snapshots') return true + if (segments[0] === 'packages' && segments[1] === 'test-support') return true + if (segments[0] === 'scripts' && (segments[1] === 'fixtures' || segments[1] === 'snapshots')) return true + if (segments.some(segment => TEST_DIRECTORY_NAMES.has(segment))) return true + const basename = segments.at(-1) ?? '' + return TEST_FILE_MARKER.test(basename) || PYTHON_TEST_FILE.test(basename) +} + +/** + * Decide whether a repository path is documentation excluded from review routing. + * @param {string} value Repository-relative path. + * @returns {boolean} Whether the path has an excluded documentation extension. + */ +export function isDocumentationPath(value) { + return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value)) +} + +/** + * Decide whether a complete modified-file patch changes comments only. + * @param {unknown} value GitHub changed-file record. + * @returns {boolean} Whether supported comment parsing removes every changed token. + */ +export function isCommentOnlyChange(value) { + if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string' + || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions) + || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false + const syntax = commentSyntax(value.filename) + if (syntax === undefined) return false + if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false + const hunks = parsePatchHunks(value.patch) + if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) { + return false + } + return hunks.values.every(({ before, after }) => + normalizedCode(before, syntax) === normalizedCode(after, syntax)) +} + +function commentSyntax(filename) { + const normalized = normalizeRepositoryPath(filename) + const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase() + const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : '' + const line = [] + const block = [] + if (C_STYLE_EXTENSIONS.has(extension)) { + line.push('//') + block.push(['/*', '*/']) + } + if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/']) + if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.') + || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#') + if (extension === 'sql') { + line.push('--') + block.push(['/*', '*/']) + } + if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['']) + return line.length === 0 && block.length === 0 ? undefined : { line, block } +} + +function parsePatchHunks(patch) { + const values = [] + let current + let additions = 0 + let deletions = 0 + for (const line of patch.split('\n')) { + if (line.startsWith('@@')) { + current = { before: [], after: [] } + values.push(current) + continue + } + if (current === undefined || line.startsWith('\\ No newline at end of file')) continue + const prefix = line[0] + const content = line.slice(1) + if (prefix === ' ') { + current.before.push(content) + current.after.push(content) + } else if (prefix === '-') { + current.before.push(content) + deletions++ + } else if (prefix === '+') { + current.after.push(content) + additions++ + } + } + return values.length === 0 ? undefined : { values, additions, deletions } +} + +function normalizedCode(lines, syntax) { + return stripComments(lines.join('\n'), syntax) + .split('\n') + .map(line => line.trimEnd()) + .filter(line => line.trim().length > 0) + .join('\n') +} + +function stripComments(source, syntax) { + let result = '' + let quote + let blockEnd + for (let index = 0; index < source.length;) { + if (blockEnd !== undefined) { + if (source.startsWith(blockEnd, index)) { + index += blockEnd.length + blockEnd = undefined + } else { + index++ + } + continue + } + const character = source[index] + if (quote !== undefined) { + result += character + index++ + if (character === '\\' && index < source.length) { + result += source[index] + index++ + } else if (character === quote) { + quote = undefined + } + continue + } + if (character === '\'' || character === '"' || character === '`') { + quote = character + result += character + index++ + continue + } + const block = syntax.block.find(([start]) => source.startsWith(start, index)) + if (block !== undefined) { + index += block[0].length + blockEnd = block[1] + continue + } + const line = syntax.line.find(marker => source.startsWith(marker, index)) + const lineStart = index === 0 || source[index - 1] === '\n' + const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '') + if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) { + const newline = source.indexOf('\n', index + line.length) + if (newline === -1) break + result += '\n' + index = newline + 1 + continue + } + result += character + index++ + } + return result +} + +/** + * Expand changed-file records into reviewable, test, documentation, and comment-only paths. + * @param {unknown[]} files Pull-request file records from GitHub. + * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts. + */ +export function classifyChangedFiles(files) { + const changedCodeFiles = new Set() + const reviewableChanges = [] + const excludedTestFiles = new Set() + const excludedDocumentationFiles = new Set() + const excludedCommentOnlyFiles = new Set() + for (const entry of files) { + if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry') + const changedLines = changedLineCount(entry) + const paths = [normalizeRepositoryPath(entry.filename)] + const commentOnly = isCommentOnlyChange(entry) + if (entry.previous_filename !== undefined) { + paths.unshift(normalizeRepositoryPath(entry.previous_filename)) + } + const reviewablePaths = [] + for (const file of new Set(paths)) { + if (isTestPath(file)) excludedTestFiles.add(file) + else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file) + else if (commentOnly) excludedCommentOnlyFiles.add(file) + else { + changedCodeFiles.add(file) + reviewablePaths.push(file) + } + } + if (reviewablePaths.length > 0) { + reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines }) + } + } + return { + changedCodeFiles: [...changedCodeFiles].sort(), + reviewableChanges, + excludedTestFiles: [...excludedTestFiles].sort(), + excludedDocumentationFiles: [...excludedDocumentationFiles].sort(), + excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(), + } +} + +function changedLineCount(entry) { + for (const field of ['additions', 'deletions']) { + if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) { + throw new Error(`changed-file ${field} must be a non-negative integer`) + } + } + const changedLines = entry.additions + entry.deletions + if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range') + return changedLines +} + +/** + * Match changed paths and rank owners by their reviewable changed LOC. + * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules. + * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records. + * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan. + */ +export function planReviewers(rules, reviewableChanges) { + const matches = [] + const reviewers = new Map() + for (const change of reviewableChanges) { + const changeOwners = new Map() + for (const file of change.paths) { + let owners = [] + for (const rule of rules) { + if (file.startsWith(rule.prefix)) owners = rule.owners + } + matches.push({ file, changedLines: change.changedLines, owners }) + for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1)) + } + for (const [key, login] of changeOwners) { + const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines + if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`) + reviewers.set(key, { login, changedLines }) + } + } + return { + matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')), + reviewers: [...reviewers.values()].sort((left, right) => { + if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1 + return left.login.localeCompare(right.login, 'en') + }), + } +} + +/** + * Create a repository-scoped GitHub JSON API caller. + * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies. + * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise} API caller. + */ +export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) { + if (!token) throw new Error('GITHUB_TOKEN is not set') + if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable') + const root = apiUrl.replace(/\/+$/u, '') + return async (path, { method = 'GET', body } = {}) => { + const response = await fetchImpl(`${root}${path}`, { + method, + headers: { + Accept: 'application/vnd.github+json', + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + 'User-Agent': 'deepseek-harness-request-review', + 'X-GitHub-Api-Version': API_VERSION, + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) + if (!response.ok) { + const responseBody = await response.text() + throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`) + } + if (response.status === 204) return undefined + return response.json() + } +} + +/** + * Fetch the complete pull-request file list or fail before routing a partial list. + * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @param {number} expectedCount Pull-request changed-file count. + * @returns {Promise} Complete changed-file records. + */ +export async function listPullRequestFiles(api, repository, pullNumber, expectedCount) { + if (!Number.isSafeInteger(expectedCount) || expectedCount < 0) { + throw new Error('pull request changed_files must be a non-negative integer') + } + if (expectedCount > MAX_PULL_REQUEST_FILES) { + throw new Error(`pull request has ${expectedCount} files; GitHub exposes at most ${MAX_PULL_REQUEST_FILES}`) + } + const files = [] + for (let page = 1; files.length < expectedCount; page++) { + const response = await api(`/repos/${repository}/pulls/${pullNumber}/files?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response) || response.length === 0) { + throw new Error(`GitHub returned ${files.length} of ${expectedCount} changed files`) + } + files.push(...response) + if (files.length > expectedCount) { + throw new Error(`GitHub returned ${files.length} files but the pull request reports ${expectedCount}`) + } + } + return files +} + +/** + * Fetch the complete chronological pull-request review list. + * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @returns {Promise} Complete review list within the supported limit. + */ +export async function listPullRequestReviews(api, repository, pullNumber) { + const reviews = [] + for (let page = 1; ; page++) { + const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array') + reviews.push(...response) + if (response.length < PAGE_SIZE) return reviews + if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) { + throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} entries`) + } + } +} + +/** + * Return users whose latest undismissed decisive review approves the pull request. + * @param {unknown[]} reviews Chronological GitHub pull-request review records. + * @returns {string[]} Approved reviewer logins in stable order. + */ +export function approvedReviewerLogins(reviews) { + const approved = new Map() + for (const review of reviews) { + if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') { + throw new Error('pull-request reviews response contains an invalid reviewer') + } + if (typeof review.state !== 'string' || !PULL_REQUEST_REVIEW_STATES.has(review.state)) { + throw new Error('pull-request reviews response contains an invalid state') + } + const key = review.user.login.toLowerCase() + if (review.state === 'APPROVED') approved.set(key, review.user.login) + else if (review.state === 'CHANGES_REQUESTED') approved.delete(key) + } + return [...approved.values()].sort((left, right) => left.localeCompare(right, 'en')) +} + +/** + * Fetch the pull request timeline used to identify workflow-authored review requests. + * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise} api GitHub API caller. + * @param {string} repository Owner/name repository identifier. + * @param {number} pullNumber Pull-request number. + * @returns {Promise} Complete timeline event list within the supported limit. + */ +export async function listPullRequestTimeline(api, repository, pullNumber) { + const events = [] + for (let page = 1; ; page++) { + const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`) + if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array') + events.push(...response) + if (response.length < PAGE_SIZE) return events + if (events.length >= MAX_TIMELINE_EVENTS) { + throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`) + } + } +} + +/** Return current requested reviewers whose latest request came from this workflow identity. */ +function workflowRequestedReviewers(events, requestedReviewers) { + const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login])) + const latestRequester = new Map() + for (const event of events) { + if (!isRecord(event) || event.event !== 'review_requested') continue + if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue + const key = event.requested_reviewer.login.toLowerCase() + if (!requested.has(key)) continue + if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') { + throw new Error('review-request timeline event has no requester login') + } + latestRequester.set(key, event.review_requester.login.toLowerCase()) + } + return [...requested] + .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER) + .map(([, login]) => login) +} + +/** Extract and validate individual logins from GitHub's requested-reviewer response. */ +function requestedReviewerLogins(response) { + if (!isRecord(response) || !Array.isArray(response.users)) { + throw new Error('requested-reviewers response has no users array') + } + return response.users.map((user) => { + if (!isRecord(user) || typeof user.login !== 'string') { + throw new Error('requested-reviewers response contains an invalid user') + } + return user.login + }) +} + +/** + * Print changed paths, reconcile workflow-authored requests with current + * ownership, and cancel workflow-authored requests on drafts. + * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, write?: (line: string) => void}} options Runtime inputs. + * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result. + */ +export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) { + const pull = pullRequestFromEvent(event) + write('This is by automated Angry Turtle Cyborg, not a human') + const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount) + const { reviewableChanges, ...classified } = classifyChangedFiles(files) + const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges) + writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file))) + writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file))) + writeList( + write, + 'Excluded documentation files', + classified.excludedDocumentationFiles.map(file => JSON.stringify(file)), + ) + writeList( + write, + 'Excluded comment-only files', + classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)), + ) + writeList( + write, + 'Owners by changed file', + plan.matches.map(({ file, changedLines, owners }) => + `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`), + ) + writeList( + write, + 'Owner relevance by changed LOC', + plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`), + ) + + const ownerCandidates = plan.reviewers.filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase()) + if (pull.draft) { + const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) + const requestedReviewers = requestedReviewerLogins(existing) + const reviewers = requestedReviewers.length === 0 + ? [] + : workflowRequestedReviewers( + await listPullRequestTimeline(api, pull.repository, pull.number), + requestedReviewers, + ) + writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`)) + if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] } + + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'DELETE', + body: { reviewers }, + }) + const requestLabel = reviewers.length === 1 ? 'request' : 'requests' + write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`) + return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers } + } + + const approvedReviewerKeys = new Set( + (ownerCandidates.length === 0 + ? [] + : approvedReviewerLogins(await listPullRequestReviews(api, pull.repository, pull.number))) + .map(login => login.toLowerCase()), + ) + const approvedOwners = ownerCandidates.filter(({ login }) => approvedReviewerKeys.has(login.toLowerCase())) + const candidates = ownerCandidates.filter(({ login }) => !approvedReviewerKeys.has(login.toLowerCase())) + writeList(write, 'Approved owners omitted from review requests', approvedOwners.map(({ login }) => `@${login}`)) + + const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`) + const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en')) + const workflowReviewers = currentReviewers.length === 0 + ? [] + : workflowRequestedReviewers( + await listPullRequestTimeline(api, pull.repository, pull.number), + currentReviewers, + ) + const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase())) + const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase())) + let retainedCountedSlots = Math.max( + 0, + MAX_COUNTED_REQUESTED_REVIEWERS + - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, + ) + const retainedWorkflowReviewerKeys = new Set() + for (const { login } of candidates) { + const key = login.toLowerCase() + if (!workflowReviewerKeys.has(key)) continue + if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key) + else if (retainedCountedSlots > 0) { + retainedWorkflowReviewerKeys.add(key) + retainedCountedSlots-- + } + } + const reviewersToCancel = workflowReviewers.filter( + login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()), + ) + const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase())) + const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase())) + const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase())) + const availableSlots = Math.max( + 0, + MAX_COUNTED_REQUESTED_REVIEWERS + - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length, + ) + writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`)) + write(`Available counted review request slots: ${availableSlots}.`) + const reviewers = candidates + .filter(({ login }) => !alreadyRequested.has(login.toLowerCase())) + .slice(0, availableSlots) + .map(({ login }) => login) + writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`)) + writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`)) + if (reviewersToCancel.length > 0) { + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'DELETE', + body: { reviewers: reviewersToCancel }, + }) + const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests' + write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`) + } + + if (reviewers.length > 0) { + await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, { + method: 'POST', + body: { reviewers }, + }) + write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`) + } + return { ...classified, requestedReviewers: reviewers, cancelledReviewers: reviewersToCancel } +} + +function pullRequestFromEvent(event) { + if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string') { + throw new Error('event has no repository.full_name') + } + if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)) { + throw new Error('event has no pull_request') + } + const { pull_request: pull } = event + if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number') + if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag') + if (typeof pull.user.login !== 'string' || !pull.user.login) throw new Error('pull request has no author login') + return { + repository: event.repository.full_name, + number: pull.number, + draft: pull.draft, + author: pull.user.login, + changedFileCount: pull.changed_files, + } +} + +function writeList(write, title, entries) { + write(`${title}:`) + if (entries.length === 0) write('- (none)') + else for (const entry of entries) write(`- ${entry}`) +} + +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +async function main() { + const eventPath = process.env.GITHUB_EVENT_PATH + if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set') + const event = JSON.parse(readFileSync(eventPath, 'utf8')) + const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8') + const api = createGitHubApi({ + token: process.env.GITHUB_TOKEN ?? '', + apiUrl: process.env.GITHUB_API_URL, + }) + await requestReviews({ event, ownershipSource, api }) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + main().catch((error) => { + process.stderr.write(`request-review failed: ${error instanceof Error ? error.message : String(error)}\n`) + process.exitCode = 1 + }) +} diff --git a/.github/review-ownership/request-review.test.mjs b/.github/review-ownership/request-review.test.mjs new file mode 100644 index 0000000000..fdb3d763a4 --- /dev/null +++ b/.github/review-ownership/request-review.test.mjs @@ -0,0 +1,869 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync } from 'node:fs' +import test from 'node:test' + +import { + approvedReviewerLogins, + classifyChangedFiles, + createGitHubApi, + isCommentOnlyChange, + isDocumentationPath, + isTestPath, + listPullRequestFiles, + listPullRequestReviews, + listPullRequestTimeline, + normalizeRepositoryPath, + parseOwnership, + planReviewers, + requestReviews, +} from './request-review.mjs' + +const ownershipSource = readFileSync(new URL('CODEOWNERS', import.meta.url), 'utf8') + +const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false } = {}) => ({ + repository: { full_name: 'deepseek-harness/deepseek-harness' }, + pull_request: { + number: 42, + draft, + changed_files: changedFiles, + user: { login: author }, + }, +}) + +test('loads the repository ownership policy without test-only directory rules', () => { + const rules = parseOwnership(ownershipSource) + const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners])) + assert.equal(rules.length, 57) + assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false) + assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false) + assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false) + assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false) + assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999']) + assert.deepEqual(ownersByPattern.get('/docs/'), ['@turtle1999']) + assert.deepEqual(ownersByPattern.get('/packages/core/'), ['@turtle1999', '@mektpoy']) + assert.deepEqual(ownersByPattern.get('/packages/llm/'), ['@LegGasai']) + assert.deepEqual(ownersByPattern.get('/packages/preset/'), ['@LegGasai', '@turtle1999']) + assert.deepEqual(ownersByPattern.get('/packages/session/'), ['@turtle1999', '@mektpoy']) + assert.deepEqual(ownersByPattern.get('/packages/subagent/'), ['@Dudu-0223']) + assert.deepEqual(ownersByPattern.get('/packages/web/'), ['@imccyu']) + assert.deepEqual(ownersByPattern.get('/python/'), ['@LegGasai']) + assert.deepEqual(ownersByPattern.get('/website/'), ['@LegGasai']) + assert.equal(rules.every(rule => rule.owners.length <= 2), true) + for (const excludedOwner of ['@tianyicui', '@kermeanx', '@pkh-xht']) { + assert.equal(rules.some(rule => rule.owners.some(owner => owner.toLowerCase() === excludedOwner)), false) + } +}) + +test('keeps turtle below one third of the eligible owned codebase', () => { + const rules = parseOwnership(ownershipSource) + const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }) + .split('\0') + .filter(file => file && existsSync(file)) + let ownedLines = 0 + let turtleLines = 0 + for (const file of trackedFiles) { + if (isTestPath(file) || isDocumentationPath(file)) continue + const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? [] + if (owners.length === 0) continue + const content = readFileSync(file) + const lines = content.length === 0 + ? 0 + : content.reduce((count, byte) => count + (byte === 10 ? 1 : 0), 0) + (content.at(-1) === 10 ? 0 : 1) + ownedLines += lines + if (owners.includes('@turtle1999')) turtleLines += lines + } + assert.ok( + turtleLines * 3 <= ownedLines, + `@turtle1999 owns ${turtleLines} of ${ownedLines} eligible owned lines`, + ) +}) + +test('rejects ownership forms the requester cannot apply safely', () => { + for (const [source, message] of [ + ['', /contains no rules/u], + ['* @owner\n', /explicit absolute directory/u], + ['/.github/ @owner\n', /hidden-directory/u], + ['/packages/*/ @owner\n', /explicit absolute directory/u], + ['/packages/core/\n', /at least one owner/u], + ['/packages/core/ @org/team\n', /individual GitHub users/u], + ['/packages/core/ @one @two @three\n', /at most 2 owners/u], + ['/packages/core/ @owner @OWNER\n', /duplicate owner/u], + ['/packages/core/ @owner\n/packages/core/ @other\n', /duplicate pattern/u], + ]) { + assert.throws(() => parseOwnership(source), message) + } +}) + +test('recognizes every repository test location and filename convention', () => { + for (const file of [ + 'apps/cli/tests/args.spec.ts', + 'apps/cli/tests/harness.ts', + 'apps/web/stress-tests/reasoning-chunks.stress.ts', + 'benchmarks/session-open/workload.ts', + 'native/landlock-run/test/entry.test.js', + 'packages/core/agent/__tests__/agent.ts', + 'packages/core/agent/benches/agent.rs', + 'packages/core/agent/src/agent.compat.spec.ts', + 'packages/core/agent/src/__snapshots__/agent.ts.snap', + 'packages/session-query/session-query/tests/test-service.ts', + 'packages/test-support/session-snapshot/src/index.ts', + 'python/sdk/src/test_client.py', + 'python/sdk/src/client_test.py', + 'scripts/fixtures/translation-prompt/response.txt', + 'scripts/session-snapshot-corpus.corpus.ts', + 'scripts/snapshots/translation-prompt-v4/request-response.expected.json', + 'snapshots/session/headless.snapshot.ts', + ]) { + assert.equal(isTestPath(file), true, file) + } +}) + +test('does not confuse production names with tests', () => { + for (const file of [ + 'apps/cli/src/testing.ts', + 'packages/core/agent/src/contest.ts', + 'packages/session/session-format/src/snapshot.ts', + 'packages/session/session-format/src/spec.ts', + 'packages/session/session-format/src/test.ts', + 'scripts/run-gates.ts', + 'vitest.config.ts', + 'vitest.bench.config.ts', + 'vitest.e2e.config.ts', + 'vitest.snapshot.config.ts', + 'vitest.web.perf.config.ts', + 'website/docs.ts', + ]) { + assert.equal(isTestPath(file), false, file) + } +}) + +test('excludes Markdown and YAML documentation extensions', () => { + for (const file of [ + 'README.md', + 'docs/architecture.MD', + 'packages/subagent/subagent/guide.yaml', + 'profiles/example.YAML', + ]) { + assert.equal(isDocumentationPath(file), true, file) + } + for (const file of [ + '.github/workflows/request-review.yml', + 'packages/subagent/subagent/src/index.ts', + 'website/docs.ts', + ]) { + assert.equal(isDocumentationPath(file), false, file) + } +}) + +test('detects comment-only changes only from complete supported patches', () => { + for (const file of [ + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"', + }, + { + filename: 'python/sdk/src/client.py', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-value = 1 # old note\n+value = 1 # new note', + }, + { + filename: 'native/landlock-run/src/main.rs', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */', + }, + ]) { + assert.equal(isCommentOnlyChange(file), true, file.filename) + } + + for (const file of [ + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note', + }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 2, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + { + filename: 'packages/core/agent/src/data.json', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}', + }, + { + filename: 'native/landlock-run/src/main.rs', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;', + }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'renamed', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + ]) { + assert.equal(isCommentOnlyChange(file), false, file.filename) + } +}) + +test('normalizes separators and rejects paths that are not repository-relative', () => { + assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts') + for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) { + assert.throws(() => normalizeRepositoryPath(file), /path/u, file) + } +}) + +test('classifies both sides of a rename independently', () => { + assert.deepEqual( + classifyChangedFiles([ + { + filename: 'packages/core/agent/tests/moved.spec.ts', + previous_filename: 'packages/core/agent/src/moved.ts', + additions: 3, + deletions: 2, + }, + { + filename: 'packages/client/store/src/restored.ts', + previous_filename: 'packages/client/store/tests/restored.spec.ts', + additions: 2, + deletions: 1, + }, + { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 }, + { + filename: 'packages/core/agent/src/commented.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + ]), + { + changedCodeFiles: [ + 'packages/client/store/src/restored.ts', + 'packages/core/agent/src/moved.ts', + ], + reviewableChanges: [ + { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 }, + { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 }, + ], + excludedTestFiles: [ + 'packages/client/store/tests/restored.spec.ts', + 'packages/core/agent/tests/moved.spec.ts', + ], + excludedDocumentationFiles: ['packages/core/agent/README.md'], + excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'], + }, + ) +}) + +test('uses the last matching ownership rule and ranks owners by changed LOC', () => { + const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n') + assert.deepEqual( + planReviewers(rules, [ + { paths: ['AGENTS.md'], changedLines: 1 }, + { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 }, + { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 }, + ]), + { + matches: [ + { file: 'AGENTS.md', changedLines: 1, owners: [] }, + { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] }, + { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] }, + ], + reviewers: [ + { login: 'core', changedLines: 8 }, + { login: 'second', changedLines: 8 }, + { login: 'broad', changedLines: 3 }, + ], + }, + ) +}) + +test('counts each changed-file record once per owner across rename paths', () => { + const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n') + const plan = planReviewers(rules, [ + { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 }, + { paths: ['packages/a/other.ts'], changedLines: 5 }, + { paths: ['packages/c/tiny.ts'], changedLines: 1 }, + ]) + assert.deepEqual(plan.reviewers, [ + { login: 'a', changedLines: 15 }, + { login: 'same', changedLines: 15 }, + { login: 'b', changedLines: 10 }, + { login: 'c', changedLines: 1 }, + ]) +}) + +test('rejects invalid changed-file LOC', () => { + for (const file of [ + { filename: 'packages/core/index.ts', deletions: 0 }, + { filename: 'packages/core/index.ts', additions: -1, deletions: 0 }, + { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 }, + ]) { + assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u) + } +}) + +test('fetches every declared changed file across pages', async () => { + const calls = [] + const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` })) + const pageTwo = [{ filename: 'packages/core/file-100.ts' }] + const api = async (path) => { + calls.push(path) + return calls.length === 1 ? pageOne : pageTwo + } + const files = await listPullRequestFiles(api, 'owner/repo', 42, 101) + assert.equal(files.length, 101) + assert.deepEqual(calls, [ + '/repos/owner/repo/pulls/42/files?per_page=100&page=1', + '/repos/owner/repo/pulls/42/files?per_page=100&page=2', + ]) +}) + +test('fails closed when GitHub cannot provide the complete file list', async () => { + let calls = 0 + await assert.rejects( + listPullRequestFiles(async () => { + calls++ + return calls === 1 ? [{ filename: 'one.ts' }] : [] + }, 'owner/repo', 42, 2), + /returned 1 of 2/u, + ) + await assert.rejects( + listPullRequestFiles(async () => [], 'owner/repo', 42, 3_001), + /at most 3000/u, + ) +}) + +test('fetches pull-request reviews across pages', async () => { + const calls = [] + const pageOne = Array.from({ length: 100 }, (_, index) => ({ + user: { login: `reviewer-${index}` }, + state: 'COMMENTED', + })) + const pageTwo = [{ user: { login: 'approver' }, state: 'APPROVED' }] + const reviews = await listPullRequestReviews(async (path) => { + calls.push(path) + return calls.length === 1 ? pageOne : pageTwo + }, 'owner/repo', 42) + + assert.equal(reviews.length, 101) + assert.deepEqual(calls, [ + '/repos/owner/repo/pulls/42/reviews?per_page=100&page=1', + '/repos/owner/repo/pulls/42/reviews?per_page=100&page=2', + ]) +}) + +test('tracks each reviewer\'s latest undismissed approval decision', () => { + assert.deepEqual(approvedReviewerLogins([ + { user: { login: 'commented-after' }, state: 'APPROVED' }, + { user: { login: 'commented-after' }, state: 'COMMENTED' }, + { user: { login: 'changes-after' }, state: 'APPROVED' }, + { user: { login: 'changes-after' }, state: 'CHANGES_REQUESTED' }, + { user: { login: 'dismissed' }, state: 'DISMISSED' }, + { user: { login: 'approved-after' }, state: 'CHANGES_REQUESTED' }, + { user: { login: 'approved-after' }, state: 'APPROVED' }, + { user: { login: 'pending-after' }, state: 'APPROVED' }, + { user: { login: 'pending-after' }, state: 'PENDING' }, + ]), ['approved-after', 'commented-after', 'pending-after']) + + assert.throws( + () => approvedReviewerLogins([{ user: { login: 'reviewer' }, state: 'UNKNOWN' }]), + /invalid state/u, + ) + assert.throws(() => approvedReviewerLogins([{ state: 'APPROVED' }]), /invalid reviewer/u) +}) + +test('fails closed when the pull-request review list exceeds its limit', async () => { + let calls = 0 + await assert.rejects( + listPullRequestReviews(async () => { + calls++ + return Array.from({ length: 100 }, () => ({ user: { login: 'reviewer' }, state: 'COMMENTED' })) + }, 'owner/repo', 42), + /exceed 3000 entries/u, + ) + assert.equal(calls, 30) +}) + +test('fails closed when the review-request timeline exceeds its limit', async () => { + let calls = 0 + await assert.rejects( + listPullRequestTimeline(async () => { + calls++ + return Array.from({ length: 100 }, () => ({ event: 'commented' })) + }, 'owner/repo', 42), + /exceeds 3000 events/u, + ) + assert.equal(calls, 30) +}) + +test('prints changed code files and requests the highest-ranked counted owner', async () => { + const trace = [] + const files = [ + { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 }, + { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 }, + { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 }, + { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 }, + { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 }, + { filename: 'AGENTS.md', additions: 200, deletions: 0 }, + ] + const api = async (path, options = {}) => { + trace.push({ type: 'api', path, options }) + if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method !== 'POST') { + return { users: [], teams: [] } + } + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + } + + const result = await requestReviews({ + event: pullRequestEvent({ author: 'turtle1999', changedFiles: files.length }), + ownershipSource, + api, + write: line => trace.push({ type: 'log', line }), + }) + + assert.deepEqual(result, { + changedCodeFiles: [ + 'packages/client/store/src/index.ts', + 'packages/core/agent/src/index.ts', + 'packages/preset/agent-presets/src/index.ts', + 'packages/subagent/subagent/src/index.ts', + ], + excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'], + excludedDocumentationFiles: ['AGENTS.md'], + excludedCommentOnlyFiles: [], + requestedReviewers: ['mektpoy'], + cancelledReviewers: [], + }) + assert.equal(trace[0].type, 'log') + assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human') + const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:') + const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:') + const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST') + assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post) + assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [ + 'Owner relevance by changed LOC:', + '- @turtle1999: 90', + '- @mektpoy: 80', + '- @Dudu-0223: 40', + '- @LegGasai: 10', + '- @imccyu: 2', + ]) + assert.deepEqual(trace[post], { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { + method: 'POST', + body: { reviewers: ['mektpoy'] }, + }, + }) +}) + +test('does not request an owner again after that owner approves', async () => { + const calls = [] + const output = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/typert/ @imccyu\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/typert/generator/src/analyzer.ts', additions: 150, deletions: 47 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) { + return [ + { user: { login: 'imccyu' }, state: 'APPROVED' }, + { user: { login: 'imccyu' }, state: 'COMMENTED' }, + ] + } + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [], teams: [] } + } + throw new Error(`unexpected API path ${path}`) + }, + write: line => output.push(line), + }) + + assert.deepEqual(result.requestedReviewers, []) + assert.equal(calls.some(call => call.options.method === 'POST'), false) + const approvedHeading = output.indexOf('Approved owners omitted from review requests:') + assert.ok(approvedHeading >= 0) + assert.equal(output[approvedHeading + 1], '- @imccyu') +}) + +test('fills the counted slot with the next owner after omitting an approved owner', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/core/ @imccyu @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) { + return [{ user: { login: 'imccyu' }, state: 'APPROVED' }] + } + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [], teams: [] } + } + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, ['mektpoy']) + assert.deepEqual(calls.find(call => call.options.method === 'POST'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, + }) +}) + +test('does not add another counted owner when one is already requested', async () => { + const calls = [] + const output = [] + const result = await requestReviews({ + event: pullRequestEvent(), + ownershipSource: '/packages/core/ @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'first' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + throw new Error(`unexpected API path ${path}`) + }, + write: line => output.push(line), + }) + + assert.deepEqual(result.requestedReviewers, []) + assert.equal(calls.some(call => call.options.method === 'POST'), false) + assert.deepEqual(output.slice(-7), [ + 'Current individual review requests:', + '- @first', + 'Available counted review request slots: 0.', + 'Review requests to cancel:', + '- (none)', + 'Reviewers to request:', + '- (none)', + ]) +}) + +test('requests at most one owner per run when turtle ranks first', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), + ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [ + { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 }, + { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 }, + ] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [], teams: [] } + } + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, ['turtle1999']) + assert.deepEqual(calls.find(call => call.options.method === 'POST'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['turtle1999'] } }, + }) +}) + +test('does not add turtle when one counted reviewer is already requested', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'first' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, []) + assert.equal(calls.some(call => call.options.method === 'POST'), false) +}) + +test('keeps the counted slot available when turtle is already requested', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'turtle1999' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result.requestedReviewers, ['mektpoy']) + assert.deepEqual(calls.find(call => call.options.method === 'POST'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, + }) +}) + +test('replaces a workflow reviewer that no longer matches current ownership', async () => { + const trace = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor' }), + ownershipSource: '/packages/core/ @mektpoy\n', + api: async (path, options = {}) => { + trace.push({ type: 'api', path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'Dudu-0223' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return [{ + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'github-actions[bot]' }, + }] + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: line => trace.push({ type: 'log', line }), + }) + + assert.deepEqual(result.requestedReviewers, ['mektpoy']) + assert.deepEqual(result.cancelledReviewers, ['Dudu-0223']) + const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:') + const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:') + const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined) + assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation) + assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223') + assert.equal(trace[requestLog + 1].line, '- @mektpoy') + assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [ + { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }, + { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'POST', body: { reviewers: ['mektpoy'] } }, + }, + ]) +}) + +test('removes excess workflow reviewers using current relevance order', async () => { + const calls = [] + const result = await requestReviews({ + event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }), + ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n', + api: async (path, options = {}) => { + calls.push({ path, options }) + if (path.endsWith('/files?per_page=100&page=1')) { + return [ + { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 }, + { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 }, + ] + } + if (path.endsWith('/reviews?per_page=100&page=1')) return [] + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return ['Dudu-0223', 'mektpoy'].map(login => ({ + event: 'review_requested', + requested_reviewer: { login }, + review_requester: { login: 'github-actions[bot]' }, + })) + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: () => {}, + }) + + assert.deepEqual(result, { + changedCodeFiles: [ + 'packages/core/agent/src/index.ts', + 'packages/subagent/subagent/src/index.ts', + ], + excludedTestFiles: [], + excludedDocumentationFiles: [], + excludedCommentOnlyFiles: [], + requestedReviewers: [], + cancelledReviewers: ['Dudu-0223'], + }) + assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), { + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }) +}) + +test('does not request reviewers for test, documentation, or comment-only changes', async () => { + const calls = [] + const output = [] + const files = [ + { filename: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 }, + { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 }, + { + filename: 'packages/core/agent/src/index.ts', + status: 'modified', additions: 1, deletions: 1, + patch: '@@ -1 +1 @@\n-// old note\n+// new note', + }, + ] + const result = await requestReviews({ + event: pullRequestEvent({ changedFiles: files.length }), + ownershipSource, + api: async (path) => { + calls.push(path) + if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] } + throw new Error(`unexpected API path ${path}`) + }, + write: line => output.push(line), + }) + assert.deepEqual(result, { + changedCodeFiles: [], + excludedTestFiles: files.slice(0, 2).map(file => file.filename), + excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename), + excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'], + requestedReviewers: [], + cancelledReviewers: [], + }) + assert.equal(calls.length, 2) + assert.deepEqual(output.slice(0, 4), [ + 'This is by automated Angry Turtle Cyborg, not a human', + 'Changed code files:', + '- (none)', + 'Excluded test files:', + ]) +}) + +test('cancels workflow-authored review requests on draft pull requests', async () => { + const trace = [] + const files = [ + { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 }, + { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 }, + { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 }, + ] + const result = await requestReviews({ + event: pullRequestEvent({ draft: true, changedFiles: files.length }), + ownershipSource, + api: async (path, options = {}) => { + trace.push({ type: 'api', path, options }) + if (path.endsWith('/files?per_page=100&page=1')) return files + if (path.endsWith('/requested_reviewers') && options.method === undefined) { + return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] } + } + if (path.endsWith('/timeline?per_page=100&page=1')) { + return [ + { + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'maintainer' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'Dudu-0223' }, + review_requester: { login: 'github-actions[bot]' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'manual-reviewer' }, + review_requester: { login: 'github-actions[bot]' }, + }, + { + event: 'review_requested', + requested_reviewer: { login: 'manual-reviewer' }, + review_requester: { login: 'maintainer' }, + }, + ] + } + if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {} + throw new Error(`unexpected API path ${path}`) + }, + write: line => trace.push({ type: 'log', line }), + }) + assert.deepEqual(result, { + changedCodeFiles: ['packages/subagent/subagent/src/index.ts'], + excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'], + excludedDocumentationFiles: ['packages/subagent/subagent/README.md'], + excludedCommentOnlyFiles: [], + requestedReviewers: [], + cancelledReviewers: ['Dudu-0223'], + }) + const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE') + assert.deepEqual(remove, { + type: 'api', + path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers', + options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } }, + }) + assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false) + assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.') +}) + +test('sends authenticated JSON and escapes an API error body', async () => { + const requests = [] + const api = createGitHubApi({ + token: 'secret', + apiUrl: 'https://github.example/api/v3/', + fetchImpl: async (url, options) => { + requests.push({ url, options }) + return new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }) + assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true }) + assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo') + assert.equal(requests[0].options.headers.Authorization, 'Bearer secret') + assert.equal(requests[0].options.headers['X-GitHub-Api-Version'], '2026-03-10') + assert.equal(requests[0].options.body, '{"value":1}') + + const failing = createGitHubApi({ + token: 'secret', + fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }), + }) + await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u) +}) diff --git a/.github/workflows/request-review.yml b/.github/workflows/request-review.yml new file mode 100644 index 0000000000..6dc839ac5b --- /dev/null +++ b/.github/workflows/request-review.yml @@ -0,0 +1,31 @@ +name: request-review + +on: + pull_request_target: + types: [opened, synchronize, reopened, ready_for_review, converted_to_draft] + +permissions: + contents: read + pull-requests: write + +concurrency: + group: request-review-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + request-review: + name: request-review + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + # SECURITY: the write-capable job executes policy from the trusted default + # branch and reads pull-request filenames only as API data. + - name: Check out trusted review policy + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + - name: Request reviewers + env: + GITHUB_TOKEN: ${{ github.token }} + run: node .github/review-ownership/request-review.mjs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index a43b428f74..49c2d8ba69 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -90,6 +90,7 @@ External packages that a workspace package resolves at runtime. The tier covers | [`micromark-util-sanitize-uri`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-sanitize-uri) | MIT | | [`micromark-util-symbol`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-symbol) | MIT | | [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT | +| [`mime-types`](https://github.com/jshttp/mime-types) | MIT | | [`negotiator`](https://github.com/jshttp/negotiator) | MIT | | [`node-addon-require-builtin`](https://www.npmjs.com/package/node-addon-require-builtin) | MIT | | [`node-pty`](https://github.com/microsoft/node-pty) | MIT | @@ -156,6 +157,7 @@ External packages **directly declared** only by repository tooling, test infrast | [`@types/fs-ext`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/js-yaml`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/jsdom`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | +| [`@types/mime-types`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/negotiator`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/node`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | | [`@types/picomatch`](https://github.com/DefinitelyTyped/DefinitelyTyped) | MIT | diff --git a/apps/web/package.json b/apps/web/package.json index 47946e5b22..6de8fd4428 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -54,6 +54,7 @@ "typescript": "^6.0.3", "vite": "^6.0.0", "vitest": "^4.1.8", - "ws": "8.21.0" + "ws": "8.21.0", + "@deepseek-ai/dsh-launch-environment": "workspace:^" } } diff --git a/apps/web/tests/expected/markdown-images/ui.expected.md b/apps/web/tests/expected/markdown-images/ui.expected.md index 3e00a67cb0..c010943b60 100644 --- a/apps/web/tests/expected/markdown-images/ui.expected.md +++ b/apps/web/tests/expected/markdown-images/ui.expected.md @@ -16,6 +16,13 @@ - paragraph: - img "Remote test image" - paragraph: Local test image +- paragraph: + - img "Workspace test image" +- paragraph: Oversized image +- paragraph: + - img "Outside workspace image" +- paragraph: Missing image +- paragraph: {{cwd}}/corrupt.png - paragraph: REMOTE_IMAGE_DONE - button "Copy": - img diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts index 35d037fb1d..a2946223dc 100644 --- a/apps/web/tests/feedback-command.e2e.ts +++ b/apps/web/tests/feedback-command.e2e.ts @@ -81,6 +81,8 @@ describe('web e2e: /feedback command acknowledgement', () => { await input.press('Enter') await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) expect(await page.getByText(/Anonymous user: [0-9a-f-]+\.$/i).count()).toBe(1) + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/lifecycle-chrome.e2e.ts b/apps/web/tests/lifecycle-chrome.e2e.ts index f96486ea34..30d9f922be 100644 --- a/apps/web/tests/lifecycle-chrome.e2e.ts +++ b/apps/web/tests/lifecycle-chrome.e2e.ts @@ -23,7 +23,7 @@ import { launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { - connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, + connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft, ZH_BROWSER_LOCALE, } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome', import.meta.url)) @@ -31,6 +31,7 @@ const FIXTURE = join(SNAPSHOT_DIR, 'session.v3.jsonl') const REPLAY_OVERRIDE = join(SNAPSHOT_DIR, 'replay.override.json') const HERO_EXPECTED = join(SNAPSHOT_DIR, 'hero.expected.md') const COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu.expected.md') +const COMMAND_MENU_ZH_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-zh.expected.md') const FUZZY_COMMAND_MENU_EXPECTED = join(SNAPSHOT_DIR, 'command-menu-fuzzy.expected.md') const PLAN_ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'plan-active.expected.md') const CONNECTION_ERROR_EXPECTED = join(SNAPSHOT_DIR, 'connection-error.expected.md') @@ -103,6 +104,26 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () await expect.poll(() => menu.count()).toBe(0) }) + it.skipIf(MODE === 'record')('localizes slash-command descriptions from the browser language', async () => { + const zhPage = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE }) + const zhTripwire = watchConsole(zhPage) + onTestFailed(() => saveFailureShot(zhPage, 'web-e2e-command-menu-zh')) + try { + await zhPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await zhPage.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + const launcher = zhPage.getByRole('button', { name: '指令' }) + await launcher.click() + const menu = zhPage.getByRole('listbox', { name: '触发候选建议' }) + await menu.waitFor({ timeout: 10_000 }) + const snapshot = await captureStableAria(zhPage, '[role="listbox"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(COMMAND_MENU_ZH_EXPECTED, snapshot, MODE) + expect(zhTripwire.pageErrors).toEqual([]) + expect(zhTripwire.warnings).toEqual([]) + } finally { + await zhPage.close() + } + }) + it.skipIf(MODE === 'record')('shows active Plan as the warn-state status action', async () => { const activeScaffold = await launchWebScaffold() const activePage = await newEnglishPage(browser) @@ -342,6 +363,14 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(await connecting.innerText()).toMatch(/^Reconnecting\.{1,3}$/) const connectingGeometry = await connectionIndicatorGeometry(connecting) expect(await connectionIndicatorTextAlignment(connecting)).toBe('left') + // Animated dots must remain hidden with their state label during hover. + await connecting.evaluate((element) => { + for (const animation of element.getAnimations({ subtree: true })) { + if (!(animation instanceof CSSAnimation)) continue + animation.pause() + animation.currentTime = 1_250 + } + }) await connecting.hover() expect(await connecting.innerText()).toBe('Reconnect now') expect(await connectionIndicatorGeometry(connecting)).toEqual(connectingGeometry) @@ -429,7 +458,8 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', () expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'session.v3.jsonl', 'replay.override.json', 'command-menu.expected.md', - 'command-menu-fuzzy.expected.md', 'connection-error.expected.md', 'hero.expected.md', 'plan-active.expected.md', + 'command-menu-fuzzy.expected.md', 'command-menu-zh.expected.md', 'connection-error.expected.md', + 'hero.expected.md', 'plan-active.expected.md', 'reloaded.expected.md', 'reloaded-expanded.expected.md', ]) }) diff --git a/apps/web/tests/live-interactions.e2e.ts b/apps/web/tests/live-interactions.e2e.ts index 9ad5766f63..3df0703839 100644 --- a/apps/web/tests/live-interactions.e2e.ts +++ b/apps/web/tests/live-interactions.e2e.ts @@ -182,7 +182,10 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { await queuedRow.getByRole('button', { name: 'Remove queued message' }).click() await expect.poll(() => queuedRow.count(), { timeout: 10_000 }).toBe(0) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await settled expect(turnEndReasons(sessionEvents).at(-1)).toBe('aborted') // Composer recovered; no streaming node lingers. The host settled first @@ -190,6 +193,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => { // frozen-partial swap is eventually consistent, so poll rather than count. await expect.poll(() => page.locator('[data-composer-input]').first().isEnabled(), { timeout: 10_000 }).toBe(true) await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0) + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) // Golden of the aborted end-state: the prompt bubble plus the frozen // partial ('partial' is the hang entry's replayed prefix) and no more. const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index 54ac8ddc9e..b8f18c6c67 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -1,8 +1,7 @@ -// Web e2e scenario: absolute HTTP(S) Markdown images. A validated session -// assembled through the Session API is seeded cold into the real web -// composition, then a separate image origin proves that the browser receives -// a real network image while local-path Markdown remains inert alt text. +// Real browser image loading and failure fallbacks through the shipped Web composition. +import { open, writeFile } from 'node:fs/promises' import { createServer, type Server } from 'node:http' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' @@ -32,6 +31,7 @@ const MODE = webSnapshotMode() const SEED_ID = 'markdown-images-web-e2e' const REMOTE_ALT = 'Remote test image' const LOCAL_ALT = 'Local test image' +const WORKSPACE_ALT = 'Workspace test image' const PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', @@ -81,7 +81,7 @@ async function stopServer(server: Server): Promise { } /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */ -function markdownImageFixture(remoteUrl: string): string { +function markdownImageFixture(remoteUrl: string, outsidePath: string): string { const session = Session.create(SessionId('markdown-image-source')) const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) session.append('turn/start', { turn: 1 }) @@ -110,6 +110,16 @@ function markdownImageFixture(remoteUrl: string): string { '', `![${LOCAL_ALT}](./local-image.png)`, '', + `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`, + '', + '![Oversized image]({{cwd}}/oversized.png)', + '', + `![Outside workspace image](${outsidePath})`, + '', + '![Missing image]({{cwd}}/missing.png)', + '', + '![]({{cwd}}/corrupt.png)', + '', 'REMOTE_IMAGE_DONE', ].join('\n'), }], @@ -142,20 +152,38 @@ function markdownImageFixture(remoteUrl: string): string { ].join('\n') } -describe('web e2e: remote Markdown image rendering', () => { +describe('web e2e: Markdown image rendering', () => { let scaffold: WebScaffold let imageOrigin: ImageOrigin let browser: Browser let page: Page let tripwire: ReturnType + const mediaResponses = new Map() beforeAll(async () => { imageOrigin = await startImageOrigin() scaffold = await launchWebScaffold({}) - await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID) + await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG) + await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image') + const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w') + try { + await oversized.truncate(20 * 1024 * 1024 + 1) + } finally { + await oversized.close() + } + const outsidePath = join(scaffold.persistenceRoot, 'outside.png') + await writeFile(outsidePath, PNG) + await writeFile(join(scaffold.workspaceCwd, 'active.html'), '

File preview

') + await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) + page.on('response', (response) => { + const url = new URL(response.url()) + if (url.pathname !== '/api/file') return + const path = url.searchParams.get('path') + if (path !== null) mediaResponses.set(path, response.status()) + }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) }, 120_000) @@ -163,17 +191,30 @@ describe('web e2e: remote Markdown image rendering', () => { afterAll(async () => { await browser?.close() await scaffold?.close() - await stopServer(imageOrigin.server) + if (imageOrigin !== undefined) await stopServer(imageOrigin.server) }) - it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => { + it('authenticates file requests and isolates directly opened active content', async () => { + const path = `/api/file?path=${encodeURIComponent(join(scaffold.workspaceCwd, 'active.html'))}` + const unauthenticated = await fetch(new URL(path, scaffold.baseUrl)) + expect(unauthenticated.status).toBe(401) + await unauthenticated.body?.cancel() + const preview = await newEnglishPage(browser) + await preview.context().addCookies(await page.context().cookies()) + try { + const response = await preview.goto(new URL(path, scaffold.baseUrl).href) + expect(response?.status()).toBe(200) + await preview.getByText('File preview', { exact: true }).waitFor() + expect(await preview.locator('body').getAttribute('data-script-ran')).toBeNull() + } finally { + await preview.close() + } + }) + + it.skipIf(MODE === 'record')('loads permitted images and shows authored text for failures', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() + await page.getByRole('treeitem').first().click() + await page.getByRole('treeitem').nth(1).click() await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), { timeout: 15_000, }).toBe(1) @@ -203,6 +244,27 @@ describe('web e2e: remote Markdown image rendering', () => { expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1) expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }]) + const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT }) + await expect.poll(() => mediaResponses.get(join(scaffold.workspaceCwd, 'valid.png'))).toBe(200) + await expect.poll(() => workspaceImage.evaluate(element => (element as HTMLImageElement).naturalWidth, undefined, { + timeout: 1_000, + })) + .toBe(1) + const outsideImage = page.getByRole('img', { name: 'Outside workspace image' }) + await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1) + for (const alt of ['Oversized image', 'Missing image']) { + await page.getByText(alt, { exact: true }).waitFor() + expect(await page.getByRole('img', { name: alt }).count()).toBe(0) + } + await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor() + expect(mediaResponses).toEqual(new Map([ + [join(scaffold.workspaceCwd, 'valid.png'), 200], + [join(scaffold.workspaceCwd, 'oversized.png'), 413], + [join(scaffold.persistenceRoot, 'outside.png'), 200], + [join(scaffold.workspaceCwd, 'missing.png'), 404], + [join(scaffold.workspaceCwd, 'corrupt.png'), 200], + ])) + const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) diff --git a/apps/web/tests/onboarding-deepseek-config.e2e.ts b/apps/web/tests/onboarding-deepseek-config.e2e.ts index ebaec71502..97cf82cd19 100644 --- a/apps/web/tests/onboarding-deepseek-config.e2e.ts +++ b/apps/web/tests/onboarding-deepseek-config.e2e.ts @@ -212,6 +212,10 @@ describe.skipIf(MODE === 'record')('web e2e: first-run DeepSeek credential setup await settings.getByLabel('上下文窗口 3').fill('131072') await settings.getByLabel('最大输出 token 数 3').fill('64K') + await expect.poll( + () => settings.getByLabel('API 密钥', { exact: true }).getAttribute('placeholder'), + { timeout: 10_000 }, + ).toBe('已配置——输入新值可替换') const modelEditor = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(MODELS_EXPECTED, modelEditor, MODE) await settings.getByRole('button', { name: '保存', exact: true }).click() diff --git a/apps/web/tests/open-in-app-ssh.e2e.ts b/apps/web/tests/open-in-app-ssh.e2e.ts new file mode 100644 index 0000000000..e8b433c9e9 --- /dev/null +++ b/apps/web/tests/open-in-app-ssh.e2e.ts @@ -0,0 +1,73 @@ +/** SSH launch behavior over a recorded conversation and the shipped Web plugin rows. */ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/open-in-app-ssh', import.meta.url)) +const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v2.jsonl', import.meta.url)) +const SEED_ID = 'open-in-app-ssh-web-e2e' +const MODE = webSnapshotMode() + +describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + openInAppEnvironment: createLaunchEnvironmentSnapshot([ + { source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }, + ]), + }) + await seedSession(scaffold, await readFile(SEED, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.addInitScript(() => { + localStorage.setItem('dsh.open-in-app.choice', JSON.stringify('vscode')) + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await browser?.close().catch((error: unknown) => failures.push(error)) + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length > 0) throw new AggregateError(failures, 'Open In SSH scenario teardown failed') + }) + + it('hides a remembered app after the real host returns an empty catalog', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-open-in-app-ssh')) + const [response] = await Promise.all([ + page.waitForResponse(response => new URL(response.url()).pathname === '/open-in-app/apps'), + (async () => { + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = page.getByRole('treeitem').first() + await group.waitFor() + if (await group.getAttribute('aria-expanded') !== 'true') await group.click() + await page.getByRole('treeitem').nth(1).click() + await page.getByText('DONE', { exact: true }).waitFor() + })(), + ]) + expect(response.status()).toBe(200) + expect(await response.json()).toEqual({ apps: [] }) + expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0) + expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0) + expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"') + const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd)) + .split(SEED_ID).join('{{seededId}}') + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'header.expected.md'), snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + await assertFixtureInventory(SNAPSHOT_DIR, ['header.expected.md']) + }) +}) diff --git a/apps/web/tests/queue-actions.e2e.ts b/apps/web/tests/queue-actions.e2e.ts index c4d5977248..7c4ce8d4bf 100644 --- a/apps/web/tests/queue-actions.e2e.ts +++ b/apps/web/tests/queue-actions.e2e.ts @@ -9,7 +9,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterEach, describe, expect, it, onTestFailed } from 'vitest' +import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { deriveReplayScript, parseSessionLog, type ReplayEntry } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { @@ -159,31 +159,36 @@ describe('web e2e: queue row actions', () => { ).toBe(2) await page.setViewportSize({ width: 640, height: 1000 }) - await expect.poll(async () => { - const metrics = await page.locator('[data-composer-card]').evaluate((composer) => { + await page.locator('[data-sidebar-collapsed="true"]').waitFor() + // The responsive sidebar and composer settle independently; sample both + // rectangles in one browser task so the comparison uses one layout. + await vi.waitFor(async () => { + const metrics = await page.evaluate(() => { const queue = document.querySelector('[data-queue-dock]') - if (!(queue instanceof HTMLElement)) throw new Error('queue dock is not mounted') + const composer = document.querySelector('[data-composer-card]') + if (queue === null || composer === null) return undefined const queueBox = queue.getBoundingClientRect() const composerBox = composer.getBoundingClientRect() - const dockInset = Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')) return { - left: queueBox.left - composerBox.left, - right: composerBox.right - queueBox.right, - dockInset, + leftInset: queueBox.left - composerBox.left, + rightInset: composerBox.right - queueBox.right, + dockInset: Number.parseFloat(getComputedStyle(composer).getPropertyValue('--dsh-composer-dock-inset')), } }) - expect(metrics.left).toBeGreaterThanOrEqual(0) - expect(metrics.right).toBeGreaterThanOrEqual(0) - expect(metrics.left).toBeCloseTo(metrics.dockInset, 1) - expect(metrics.right).toBeCloseTo(metrics.dockInset, 1) - return true - }, { timeout: 10_000 }).toBe(true) + expect(metrics).toBeDefined() + expect(metrics!.leftInset).toBeGreaterThanOrEqual(0) + expect(metrics!.rightInset).toBeGreaterThanOrEqual(0) + expect(metrics!.leftInset).toBeCloseTo(metrics!.dockInset, 1) + expect(metrics!.rightInset).toBeCloseTo(metrics!.dockInset, 1) + }, { timeout: 10_000 }) await page.setViewportSize({ width: 1680, height: 1000 }) const editRow = page.locator('[data-queue-dock] li', { hasText: EDIT }) await editRow.getByRole('button', { name: 'Edit queued message' }).click() const editor = page.getByRole('textbox', { name: 'Edit queued message' }) await editor.fill(EDITED) + await page.getByRole('button', { name: 'Save queued message' }).hover() + await page.getByRole('tooltip', { name: 'Save queued message', exact: true }).waitFor() const editingSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(EDITING_EXPECTED, editingSnapshot, MODE) await settleQueueAction(() => page.getByRole('button', { name: 'Save queued message' }).click(), EDITED) @@ -192,6 +197,11 @@ describe('web e2e: queue row actions', () => { const removeRow = page.locator('[data-queue-dock] li', { hasText: REMOVE }) await settleQueueAction(() => removeRow.getByRole('button', { name: 'Remove queued message' }).click(), EDITED) await expect.poll(() => page.getByText(REMOVE, { exact: true }).count()).toBe(0) + // The queue stream can remove the row before the mutation reply clears busy. + const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true }) + await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true) + await remainingEdit.hover() + await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor() const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE) @@ -229,16 +239,18 @@ describe('web e2e: queue row actions', () => { { timeout: 10_000 }, ).toBe(2) - await page.getByRole('button', { name: 'Stop generating' }).click() + const stopButton = page.getByRole('button', { name: 'Stop generating' }) + await stopButton.hover() + await page.getByRole('tooltip', { name: 'Stop generating', exact: true }).waitFor() + await stopButton.click() await firstSettled await expect.poll(() => page.getByRole('button', { name: 'Stop generating' }).count()) .toBe(0) await expect.poll(() => page.getByRole('button', { name: 'Remove queued message' }).count()) .toBe(2) - // Stop becomes Send under the pointer; dismiss its hover tooltip before capture. - await page.mouse.move(0, 0) - await expect.poll(() => page.getByRole('tooltip').filter({ hasText: 'Send message' }).count()).toBe(0) + // The disabled Send button must dismiss the active Stop tooltip without mouseleave. + await expect.poll(() => page.getByRole('tooltip').count()).toBe(0) const preservedSnapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) await compareOrRefreshGolden(PRESERVED_EXPECTED, preservedSnapshot, MODE) const expanded = await captureExpandedTurnProcessAria( diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 3194684030..4bc734aa4b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -31,6 +31,7 @@ import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import Group from '@deepseek-ai/cordis-plugin-group' @@ -284,6 +285,8 @@ export interface WebScaffold { /** Options for {@link launchWebScaffold}. */ export interface LaunchOptions { + /** Enable the real Open In rows with deterministic launch-environment facts. */ + openInAppEnvironment?: LaunchEnvironmentSnapshot /** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */ compareReplaySession?: boolean /** @@ -600,13 +603,10 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise() const stopObservingSessions = ctx.on('session/created', (session) => { observedSessions.set(session.id, session) diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index 73308e1bf5..933d15035c 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -502,6 +502,9 @@ describe('web e2e: seeded history renders through cold resume', () => { const userId = userLine?.match(/^Anonymous user: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') + // command/done can arrive before the submit reply releases the composer. + await expect.poll(() => input.textContent(), { timeout: 10_000 }).toBe('') + await expect.poll(() => page.getByRole('button', { name: 'Add attachment' }).isEnabled(), { timeout: 10_000 }).toBe(true) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') .split(userId).join('{{userId}}') diff --git a/apps/web/tests/workflow-run.e2e.ts b/apps/web/tests/workflow-run.e2e.ts index 5b800d4b74..0da8f851e0 100644 --- a/apps/web/tests/workflow-run.e2e.ts +++ b/apps/web/tests/workflow-run.e2e.ts @@ -7,7 +7,7 @@ import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import { assertFixtureInventory, captureStableAria, compareOrRefreshGolden, @@ -32,6 +32,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = let page: Page let tripwire: ReturnType let prompt: string + const releaseChild = Promise.withResolvers() const waitForParentSettlement = (): Promise => new Promise((resolve, reject) => { let dispose = (): void => {} @@ -53,9 +54,14 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = scaffold = await launchWebScaffold({ replayFixture: PARENT_FIXTURE, replayChildFixtures: [CHILD_FIXTURE], - paceMs: 50, compareReplaySession: false, }) + // Keep the live child available throughout disclosure, layout, and navigation checks. + scaffold.ctx.on('llm/stream', async function* (options, next) { + const session = options.sessionId === undefined ? undefined : scaffold.ctx.sessions.get(options.sessionId) + if (session?.header.origin === 'subagent') await releaseChild.promise + yield* next() + }, { prepend: true }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) @@ -65,6 +71,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = }, 120_000) afterAll(async () => { + releaseChild.resolve(undefined) await browser?.close() await scaffold?.close() }) @@ -72,6 +79,9 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = it('shows the live member, opens its local child, then retains the settled record beside the tool row', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workflow-run-live')) const settled = waitForParentSettlement() + onTestFinished(() => { + releaseChild.resolve(undefined) + }) const input = page.locator('[data-composer-input]').first() await input.fill(prompt) await input.press('Enter') @@ -160,6 +170,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () = const sessions = page.getByRole('tree', { name: 'Sessions' }) await sessions.getByRole('treeitem', { name: /Use the workflow tool exactly/ }).click() + releaseChild.resolve(undefined) await settled await expandTurnProcesses(page) await page.locator('[data-workflow-run][data-run-status="completed"]').waitFor() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index c3df97ed86..c471035ce4 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -80,6 +80,11 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff () => scaffold.ctx.workspaceRegistry.resolveByPath(join(parent, name)), { timeout: 10_000 }, ).not.toBeUndefined() + // Adoption also opens a blank Session. Its selected row must reach the + // browser before a later workspace action can depend on the row positions. + const row = page.getByRole('treeitem').filter({ hasText: name }).first() + const section = row.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]') + await section.locator('[role="treeitem"][aria-selected="true"]').waitFor({ timeout: 10_000 }) } /** diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 97e9c01d3d..1e3fbcd850 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -66,6 +66,7 @@ "tests/web-search-round.e2e.ts", "tests/file-upload-round.e2e.ts", "tests/message-actions.e2e.ts", + "tests/open-in-app-ssh.e2e.ts", "tests/message-feedback.e2e.ts", "tests/message-feedback-layout.e2e.ts", "tests/markdown-images.e2e.ts", diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 7f2b65789e..3957eee85a 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 37437b37a5f3a085f932bc8bffeb0c707afa4117 -config-catalog.zh.md: 44ecc9d55da4efd0aaca2c12fd12e8ce349251b8 +config-catalog.md: 9099ca894f0cab6fb8030c5ffced74cf7547df77 +config-catalog.zh.md: a9d7502de051857bb947361c1808a48ebcedb2ee diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 37437b37a5..9099ca894f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -213,7 +213,7 @@ export interface Config { } ``` -Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts) +Source: [`packages/api/session-controller/src/index.ts:70`](../packages/api/session-controller/src/index.ts) @@ -948,7 +948,7 @@ export interface Config { } ``` -Source: [`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +Source: [`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) @@ -2998,8 +2998,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** @@ -3049,7 +3049,7 @@ export interface Config { Depends on: [`AgentOptions`](subsystems/core.md) -Source: [`packages/subagent/tool-subagent/src/index.ts:47`](../packages/subagent/tool-subagent/src/index.ts) +Source: [`packages/subagent/tool-subagent/src/index.ts:48`](../packages/subagent/tool-subagent/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 44ecc9d55d..a9d7502de0 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -950,7 +950,7 @@ export interface Config { } ``` -来源:[`packages/host/open-in-app/src/index.ts:49`](../packages/host/open-in-app/src/index.ts) +来源:[`packages/host/open-in-app/src/index.ts:50`](../packages/host/open-in-app/src/index.ts) @@ -3000,8 +3000,8 @@ export interface Config { */ toolName?: string /** - * Sample the Host `subagent-model-selection` user setting for each new - * top-level session and inherit that decision in its child sessions. + * Sample the Host `subagent-model-selection` setting for each new top-level + * Session and inherit that decision in its child Sessions. */ modelSelectionSettings?: boolean /** diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index fe64fd1e42..2ffdac7b4e 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/i18n/README.md -README.md: 1b6ed4a3f4bff05cdb52b28fc81c5d7b47a9a260 -README.zh.md: e28db9bb230cf5fb344fdc3ddc415883f5ebe6b3 +README.md: 55ae07c18e09fde141ecf5344f715dfa25658325 +README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a diff --git a/docs/i18n/README.md b/docs/i18n/README.md index 1b6ed4a3f4..55ae07c18e 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -51,6 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`. - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction. - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior. +- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only. - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them. **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index e28db9bb23..674edeb9da 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -53,6 +53,7 @@ - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。 +- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。 diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 837ef43588..ba1dd07a2f 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: 60b58555c89a8d15cf8876204a6fbe445a876902 -module-graph.zh.md: e52dcb2256f049f645b5dedaca5f7e643af76c72 +module-graph.md: 233ae6b3fb49b07a2f7237aef2674c782df07720 +module-graph.zh.md: 64c5aa749aa76631c308d4b724ed1cd356068019 diff --git a/docs/module-graph.md b/docs/module-graph.md index 60b58555c8..233ae6b3fb 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1071,6 +1071,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1421,7 +1422,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e52dcb2256..64c5aa749a 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1073,6 +1073,7 @@ flowchart TD pkg_api_session_controller --> pkg_client_file_upload pkg_api_session_controller --> pkg_commands pkg_api_session_controller --> pkg_file_reference + pkg_api_session_controller --> pkg_fs pkg_api_session_controller --> pkg_jobs pkg_api_session_controller --> pkg_llm pkg_api_session_controller --> pkg_native_command @@ -1423,7 +1424,7 @@ flowchart TD | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) | | [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) | | [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 0f26f9f21b..07faf5ab83 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: b09eed1e28dde50272b1d444a471b111bcdfe77a -core.zh.md: 026db4f5d69e63bb4e1a5e7ead0fa3fe1ef9cbbd +core.md: 27ff30a9e63c86ffb54ccf57dea18ebf3fe39846 +core.zh.md: cedd113de5d5d551b8b558f9f33c7cacf6aa4953 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index b09eed1e28..27ff30a9e6 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -46,9 +46,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) composes the agent's scoped world while both ids are still unpublished — everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly — and may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. +`CreateAgentOptions` carries the shared identity and everything a fresh agent needs before publication: an optional live `parentAgent`, session metadata (`meta` — validated `cwd`, fork lineage, the `isSeeded` marker, origin classification, delegation depth, and `agentPreset`), the exact fork cut in sibling field `inheritedEventCount`, an optional `seed` replay prefix, per-agent `AgentOptions`, a creation-only cancellation `signal`, and `setup`. `ResumeAgentOptions` is the persisted-identity counterpart: `resumeSessionId`, `parentAgent`, `agentOptions`, `signal`, and `setup`. The `setup` callback (`AgentSetup`) receives `(agentCtx, agent)` while both ids are still unpublished: the context owns scoped registrations, while the explicit Agent supplies the exact child Session without a reverse property on the Context. Everything registered through `agentCtx` exists before `agent/created` and the first prompt assembly. Setup may return a synchronous commit invoked immediately before publication; a setup rejection, commit throw, or owner disposal rolls the transaction back without publishing either id. -`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. +`AgentFactory` is the creation interface behind the registry: the loop registers its factory via `ctx.agents.setFactory()`, so consumers use `ctx.agents` without depending on the concrete loop package. A runtime child creator sets `options.parentAgent`; the registry passes the options and caller Context to the factory without deriving one from the other. The exact `create`/`resume` signatures and rollback contracts are in the [generated section](#ctxagents--agentregistry) below. ## The agent handle @@ -463,7 +463,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick @@ -471,7 +471,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise @@ -736,7 +736,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -801,7 +802,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -810,7 +811,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -822,7 +823,8 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -842,7 +844,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 026db4f5d6..cedd113de5 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -48,9 +48,9 @@ interface AgentHandle { } ``` -`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 都尚未发布时组装 agent 的作用域世界——凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在——并可返回一个在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 +`CreateAgentOptions` 携带共享标识以及新 agent 发布前所需的一切:可选的存活 `parentAgent`、会话元数据(`meta`——已校验的 `cwd`、fork 谱系、`isSeeded` 标记、来源分类、委派深度与 `agentPreset`)、同级字段 `inheritedEventCount` 所表示的精确 fork cut、可选的 `seed` 回放前缀、按 agent 的 `AgentOptions`、仅创建期有效的取消 `signal`,以及 `setup`。`ResumeAgentOptions` 是持久标识的对应项:`resumeSessionId`、`parentAgent`、`agentOptions`、`signal` 与 `setup`。`setup` 回调(`AgentSetup`)在两个 id 均未发布时接收 `(agentCtx, agent)`:上下文拥有作用域注册,显式 Agent 提供确切的子 Session,Context 无需反向属性。凡经 `agentCtx` 注册的内容都先于 `agent/created` 与第一次提示词组装存在。Setup 可以返回在发布前一刻调用的同步 commit;setup 拒绝、commit 抛出或所有者 dispose(资源释放)都会回滚事务,两个 id 均不发布。 -`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 +`AgentFactory` 是注册表背后的创建接口:循环经 `ctx.agents.setFactory()` 注册其工厂,因此消费方使用 `ctx.agents` 时无需依赖具体循环包。运行时子 Agent 的创建方设置 `options.parentAgent`;注册表把 options 与调用方 Context 传给工厂,不从其中一项推导另一项。确切的 `create`/`resume` 签名及回滚约定见下方[生成区块](#ctxagents--agentregistry)。 @@ -473,7 +473,7 @@ async create(id: SessionId, options: AgentOptions = {}, meta: Pick @@ -481,7 +481,7 @@ async createAgent(ownerCtx: Context, options: CreateAgentOptions): Promise @@ -746,7 +746,8 @@ Initiator methods provide same-process causal attribution only. Ambient presence * Read the Agent that initiated the inherited asynchronous driver chain. * Use this optional form for logging, tracing, metrics, or host attribution * that also supports agentless calls. When a parent creates a child, setup - * reports the causal parent while `agentCtx.agent` identifies the child. + * reports the causal parent while the setup callback's Agent parameter + * identifies the child. * @returns the inherited Agent, or `undefined` outside an initiator boundary * and inside an explicit clearing boundary. * @throws when this service instance has been disposed. @@ -811,7 +812,7 @@ setFactory(factory: AgentFactory): () => void * agent): this constructs the agent and its session. Rejects if no factory is * registered or creation/setup fails. The resolved {@link AgentHandle} lets * the owner tear down exactly this agent. - * @param options - shared identity, session seed/metadata, and agent options. + * @param options - shared identity, optional live parent, session seed/metadata, and agent options. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async create(options: CreateAgentOptions): Promise @@ -820,7 +821,7 @@ async create(options: CreateAgentOptions): Promise * Load a persisted session and resume an agent on it through the registered * factory. Rejects if no factory is registered; the factory rejects if * session persistence is not configured or persistence/setup fails. - * @param options - persisted identity, configuration, and optional setup. + * @param options - persisted identity, optional live parent, configuration, and setup. * @returns the handle after setup, rollback-covered publication, and loop start complete. */ async resume(options: ResumeAgentOptions): Promise @@ -832,7 +833,8 @@ async resume(options: ResumeAgentOptions): Promise * (`scopeTarget(agent, agent)`): the subject is the agent in hand, so the * emits are scope-filtered regardless of which context invoked `register` * (calling through `agent.ctx` scopes EFFECTS; dispatch scoping always - * requires passing the carrier). Returns the disposer. + * requires passing the carrier). The entry is a runtime root; factory-backed + * creation uses `options.parentAgent` for child ownership. Returns the disposer. * @param agent - the already-constructed agent to record in the store. * @returns the EXACT Cordis effect disposer (single-shot; a repeat call * returns undefined without awaiting an in-flight teardown). Exact @@ -852,7 +854,7 @@ register(agent: Agent): () => void * returned detach closure into its pre-installed composite teardown before * calling {@link announce}. Ordinary callers use {@link register}. * @param agent - the prepared, unpublished agent. - * @param owner - live agent whose scoped context created this agent, or + * @param owner - explicitly supplied live runtime owner, or * undefined for a top-level runtime root. This is runtime ownership, not * the resumed session's durable parent lineage. * @returns an idempotent closure that removes this exact entry and emits diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 3c53237bad..f64a9d5c91 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: cf711185d02808f70a71a46c6d6c1af4da4a7334 -subagent.zh.md: e97b4ab965d279f04ddda5e8ae66621b0198a5cd +subagent.md: 55e8f8af23cb71c1103c017c09e9dfd2ef365b75 +subagent.zh.md: 71a71b33771ff83ccaa6802358b8534c11930181 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index cf711185d0..55e8f8af23 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -471,11 +471,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index e97b4ab965..71a71b3377 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -475,11 +475,11 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp ### `ctx.subagentModelSelection` — `SubagentModelSelectionConfig` -Singleton settings owner read by delegation tools when an Agent is published. +Singleton settings owner read when delegation tools are composed for a Session. ```ts cordis-catalog /** - * Read a detached selection preference for the next eligible Agent publication. + * Read a detached selection preference for the next eligible Session composition. * @returns the enabled state and exact allowed routes. */ current(): SubagentModelSelectionSettings diff --git a/package.json b/package.json index 422cce1ce6..1c4c8f6f86 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "test:expected": "vitest run --config vitest.expected.config.ts", "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts", "test:issue-management": "node .github/issue-management/policy.test.mjs", + "test:request-review": "node --test .github/review-ownership/request-review.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", "test:snapshot:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.snapshot.config.ts", @@ -101,6 +102,7 @@ "verify-package-invariants": "tsx scripts/verify-package-invariants.ts", "verify-built-package-invariants": "node scripts/verify-built-package-invariants.mjs", "verify-package-readme-model-experience": "tsx scripts/verify-package-readme-model-experience.ts", + "verify-package-readme-summaries": "tsx scripts/verify-package-readme-summaries.ts", "verify-mermaid": "tsx scripts/verify-mermaid.ts", "verify-agent-note-classification": "tsx scripts/verify-agent-note-classification.ts", "verify-agent-note-format": "tsx scripts/verify-agent-note-format.ts", diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index d0ba9d7e87..4be563b5f5 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/acp/acp/README.md -README.md: 6f0411d993f67f96d599a811ce583f8e166b76b6 -README.zh.md: 641e0801ce85caefa90c0d9fdd32c3886ada82b8 +README.md: 635e0e6993a65f403a5cc89f2c9d147b48308a04 +README.zh.md: 65d7d3b2f9f66701c801c910048a501f565b0a46 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index 6f0411d993..635e0e6993 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-acp` lets trusted programs drive persistent DeepSeek Harness agents over the standard [Agent Client Protocol](https://agentclientprotocol.com): create or resume sessions, list resumable sessions, attach standard MCP servers, select a model and reasoning effort, prompt or cancel work, receive semantic execution updates, and close one session without affecting others. It is built for automation — out-of-process subagents, test runners, and scripted controllers — rather than the DSH user interface: it emits standard ACP messages, thoughts, generic tool lifecycle, configuration, and context usage, never private DSH presentation data or methods. Session persistence enables list, resume, and close across process restarts, while deletion, fork, transcript replay, additional directories, and interactive UI surfaces remain unsupported. The repository's own ACP client is `dsh-subagent-acp`, and `pnpm dsh --profile acp` starts a ready-to-use server. Setup and usage come first; the implementation details live in a collapsible developer section below. +`dsh-acp` lets trusted programs automate persistent DeepSeek Harness agents through the standard [Agent Client Protocol](https://agentclientprotocol.com): create or resume sessions, select a model and reasoning effort, attach MCP servers, submit or cancel work, receive semantic updates, and close sessions independently. Choose it for out-of-process subagents, test runners, and scripted controllers; it intentionally omits DSH-specific presentation data and interactive UI features. Persistence supports listing, resuming, and closing sessions across process restarts, but deletion, forks, transcript replay, and additional directories are unsupported. Run `pnpm dsh --profile acp` to start the server; use `dsh-subagent-acp` as the repository client. ## Table of Contents diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index 641e0801ce..65d7d3b2f9 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-acp` 让受信程序可以通过标准 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 驱动持久 DeepSeek Harness agent:创建或恢复会话、列出可恢复会话、挂载标准 MCP 服务器、选择模型与推理强度、发送或取消工作、接收语义执行更新,并关闭一个会话而不影响其他会话。它是为自动化而生的——进程外 subagent、测试运行器与脚本化控制器——而不是 DSH 用户界面:它发送标准 ACP 消息、thought、通用工具生命周期、配置与上下文用量,绝不发送 DSH 私有呈现数据或方法。会话持久化支持跨进程重启的列出、恢复与关闭,而删除、fork、转录回放、附加目录与交互式 UI 界面仍不支持。仓库自带的 ACP 客户端是 `dsh-subagent-acp`,`pnpm dsh --profile acp` 会启动一个开箱即用的服务器。设置与用法在前;实现细节放在下方可折叠的开发者章节中。 +`dsh-acp` 让受信程序通过标准 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 自动操作持久 DeepSeek Harness agent:创建或恢复会话、选择模型与推理强度、挂载 MCP 服务器、提交或取消工作、接收语义更新,并独立关闭会话。进程外 subagent、测试运行器与脚本化控制器适合选择它;它刻意不提供 DSH 专用呈现数据与交互式 UI 功能。持久化支持跨进程重启列出、恢复与关闭会话,但不支持删除、fork、转录回放与附加目录。运行 `pnpm dsh --profile acp` 可启动服务器;仓库客户端使用 `dsh-subagent-acp`。 ## 目录 diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts index a4e93f1e1a..3fff075359 100644 --- a/packages/acp/acp/src/session.ts +++ b/packages/acp/acp/src/session.ts @@ -150,10 +150,7 @@ export class AcpSession { resumeSessionId: options.sessionId, agentOptions: options.agentOptions, signal: options.signal, - setup: async (agentCtx) => { - const agent = agentCtx.agent - /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */ - if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup') + setup: async (agentCtx, agent) => { modelControl = new AcpModelControl( ctx.llm, selectionFor(agent.session.requestHeader(), options.fallbackSelection), diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 4ec7c3f9fd..ae813fde1a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -457,12 +457,7 @@ export class TypertGatewayService extends Service implements TypertGateway { private startRemoteEvent(source: TypertRemoteEventInvocation): void { try { assertRemoteEventName(source) - const context = this.ctx.typert.contexts.identifyHost(source.context.value) - if (context === undefined) { - source.resolve({ kind: 'next' }) - return - } - if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + if (!isRemoteEventAgentId(source.context.agentId)) { throw new TypeError( 'typert gateway: scoped Remote events require a non-empty Agent identity', ) @@ -476,7 +471,7 @@ export class TypertGatewayService extends Service implements TypertGateway { () => () => { this.cancelRemoteEvent( pending, - new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + new Error('typert gateway: Remote event Agent Context was released'), ) }, `api-gateway: Remote event ${JSON.stringify(source.event)}`, @@ -500,7 +495,7 @@ export class TypertGatewayService extends Service implements TypertGateway { type: 'waterfall', event: source.event, eventId: id, - agentId: context.identity, + agentId: source.context.agentId, request: projected.request, }, deliveries: new Set(), diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index b456efb4d0..f325d46e1a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -28,10 +28,12 @@ export interface TypertRemoteEventFrame { /** Live Host values used to project one scoped Remote Event. */ export interface TypertRemoteEventContext { - /** Live Host Context identified by the registered Host adapters. */ + /** Live Agent Context that owns cancellation of the forwarded waterfall. */ readonly value: Context /** Agent object carried directly by the waterfall request. */ readonly subject: object + /** Agent identity read directly from the scoped event subject. */ + readonly agentId: string } /** Result returned from a Client waterfall, or delegation back to the Host chain. */ diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts index 2ad8c0225a..6244e1ec39 100644 --- a/packages/api/gateway/tests/gateway-stream.host.spec.ts +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -188,6 +188,7 @@ function pendingInvocation( context: Context, signal?: AbortSignal, prompt = 'ship', + identity: unknown = agentId('agent-1'), ): PendingInvocationProbe { const subject = { ctx: context } const settled = Promise.withResolvers() @@ -201,7 +202,7 @@ function pendingInvocation( dispatch: { event: 'fixture/approval', request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, - context: { value: context, subject }, + context: { value: context, subject, agentId: identity as string }, resolve, reject, }, @@ -466,13 +467,7 @@ describe('Typert Remote streams', () => { it('cancels a pending waterfall when its source rejects during removal', async () => { const { ctx } = await setup(true) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, - resolve: id => id === 'agent-removal' ? agent : undefined, - }) - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-removal')) const rejected = expect(pending.outcome).rejects.toThrow( 'forwarded Remote event source was removed', ) @@ -497,7 +492,7 @@ describe('Typert Remote streams', () => { client.socket.close() }) - it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + it('rejects malformed scoped invocations and delegates a released Context', async () => { const { ctx } = await setup(false) const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) @@ -514,28 +509,15 @@ describe('Typert Remote streams', () => { await rejected } - const unavailable = pendingInvocation(ctx) - source.push(unavailable.dispatch) - await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) - expect(unavailable.reject).not.toHaveBeenCalled() - let selected = ctx.extend() - let identity: unknown = 1n - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === selected ? identity as AgentWireId : undefined, - resolve: () => selected, - }) - const nonJsonIdentity = pendingInvocation(selected) + const nonJsonIdentity = pendingInvocation(selected, undefined, 'ship', 1n) const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( 'require a non-empty Agent identity', ) source.push(nonJsonIdentity.dispatch) await nonJsonRejected - identity = 'agent-invalid-request' - const invalidRequest = pendingInvocation(selected) + const invalidRequest = pendingInvocation(selected, undefined, 'ship', agentId('agent-invalid-request')) const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( 'must carry its scoped Agent directly', ) @@ -548,18 +530,16 @@ describe('Typert Remote streams', () => { const staleFiber = ctx.plugin(() => {}) await staleFiber selected = staleFiber.ctx - identity = 'agent-stale' await staleFiber.dispose() - const stale = pendingInvocation(selected) + const stale = pendingInvocation(selected, undefined, 'ship', agentId('agent-stale')) source.push(stale.dispatch) await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) expect(stale.reject).not.toHaveBeenCalled() selected = ctx.extend() - identity = 'agent-cancelled' const abort = new AbortController() abort.abort('fixture non-error cancellation') - const cancelled = pendingInvocation(selected, abort.signal) + const cancelled = pendingInvocation(selected, abort.signal, 'ship', agentId('agent-cancelled')) const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ message: 'typert gateway: Remote event was cancelled', cause: 'fixture non-error cancellation', @@ -597,19 +577,13 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, - resolve: id => id === 'agent-collision' ? agent : undefined, - }) const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) const firstAbort = new AbortController() const secondAbort = new AbortController() - const first = pendingInvocation(agent, firstAbort.signal, 'first') - const second = pendingInvocation(agent, secondAbort.signal, 'second') + const first = pendingInvocation(agent, firstAbort.signal, 'first', agentId('agent-collision')) + const second = pendingInvocation(agent, secondAbort.signal, 'second', agentId('agent-collision')) source.push(first.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -651,12 +625,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-a') const second = await openEventClient(ctx, 'events-b') const pending = pendingInvocation(agent) @@ -705,14 +673,8 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, - resolve: id => id === 'agent-rejected' ? agent : undefined, - }) const client = await openEventClient(ctx, 'events-rejected') - const pending = pendingInvocation(agent) + const pending = pendingInvocation(agent, undefined, 'ship', agentId('agent-rejected')) source.push(pending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const frame = deliveredInvocation(client)! @@ -745,12 +707,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const first = await openEventClient(ctx, 'events-next-a') const second = await openEventClient(ctx, 'events-next-b') const pending = pendingInvocation(agent) @@ -778,13 +734,7 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined, - resolve: id => id === 'agent-late-client' ? agent : undefined, - }) - const pending = pendingInvocation(agent, undefined, 'before-connect') + const pending = pendingInvocation(agent, undefined, 'before-connect', agentId('agent-late-client')) source.push(pending.dispatch) await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) @@ -811,12 +761,6 @@ describe('Typert Remote streams', () => { const source = new RemoteEventSourceProbe() const unregister = ctx.typertGateway.registerRemoteEvents(source.source, REMOTE_HOST) const agent = ctx.extend() - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: candidate => candidate === agent ? agentId('agent-1') : undefined, - resolve: id => id === 'agent-1' ? agent : undefined, - }) const original = await openEventClient(ctx, 'events-original') const pending = pendingInvocation(agent) source.push(pending.dispatch) @@ -848,24 +792,10 @@ describe('Typert Remote streams', () => { const contextFiber = ctx.plugin(() => {}) await contextFiber const contextAgent = contextFiber.ctx - ctx.typert.contexts.registerHost('agent', { - wire: 'agentId', - wireTypeSymbol: '@fixture#AgentId', - identity: (candidate) => { - if (candidate === signalAgent) return agentId('agent-signal') - if (candidate === contextAgent) return agentId('agent-context') - return undefined - }, - resolve: (id) => { - if (id === 'agent-signal') return signalAgent - if (id === 'agent-context') return contextAgent - return undefined - }, - }) const client = await openEventClient(ctx, 'events-cancel') const abort = new AbortController() - const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal', agentId('agent-signal')) source.push(signalPending.dispatch) await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) const signalFrame = deliveredInvocation(client)! @@ -886,7 +816,7 @@ describe('Typert Remote streams', () => { }) }) - const contextPending = pendingInvocation(contextAgent, undefined, 'context') + const contextPending = pendingInvocation(contextAgent, undefined, 'context', agentId('agent-context')) source.push(contextPending.dispatch) let contextFrame: RemoteEventInvocationFrame | undefined await vi.waitFor(() => { @@ -899,7 +829,7 @@ describe('Typert Remote streams', () => { && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined expect(contextFrame).toBeDefined() }) - const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Agent Context was released') await contextFiber.dispose() await contextOutcome await vi.waitFor(() => { diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index b61a6189f8..edbd83dc56 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -1344,7 +1344,6 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', - identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index b29bc48355..e4d442d64f 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/remotes/README.md -README.md: 1a6c311db9d456db17d942b56cc133799f355a88 -README.zh.md: cba94598868db8401ea512bdb6c274fa2fe098e5 +README.md: 52ef6223f4d7770224df1b1c5d783962c55f73f6 +README.zh.md: eab5d2c6642a7899fac60d8c666ee55057eaea05 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 1a6c311db9..52ef6223f4 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -42,7 +42,7 @@ This package owns no physical transport or Host service discovery. It projects t The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. -The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. Each scoped waterfall request must carry its routed Agent directly as `request.agent`; the Host rejects a missing or mismatched identity before forwarding. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active and carries the Host home for Client path display. Withdrawing the registration aborts active streams. ## Build boundary diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index cba9459886..eab5d2c664 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -42,7 +42,7 @@ Client 组合挂载 Commands、凭据、settings、Goal、动态 Cordis、文件 监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 -Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。每个作用域 waterfall 请求都必须以 `request.agent` 直接携带路由所用的 Agent;Host 会在转发前拒绝缺失或不匹配的身份。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项既能证明增量投递已就绪,也会携带供 Client 显示路径的 Host home。撤回注册会中止活动 stream。 ## 构建边界 diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3cfba6343d..1687df643c 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -60,6 +60,7 @@ }, "devDependencies": { "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", "@deepseek-ai/dsh-api-settings-controller": "workspace:^", diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 63b87bd0c5..c75e55eefd 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -2,6 +2,7 @@ import { homedir } from 'node:os' import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' import type { TypertRemoteEventDispatch, TypertRemoteEventInvocation, @@ -57,17 +58,17 @@ function remoteEventSource(ctx: Context): TypertRemoteEventSource { request: object, next: () => unknown, ) { - const subject = carrierKeyOf(this) - if (subject === undefined) return next() - const value = Reflect.get(subject, 'ctx') as unknown - if (typeof value !== 'object' || value === null) { - throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + const carrierAgent = carrierKeyOf(this) + if (carrierAgent === undefined) return next() + const agent = (request as { readonly agent?: Agent }).agent + if (agent === undefined || agent !== carrierAgent) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} must carry its Agent directly`) } return forwardWaterfall( queue, event, request, - { value: value as Context, subject }, + { value: agent.ctx, subject: agent, agentId: agent.id }, next, ) }) as never) diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts index 2e6b0ed7e1..3a7c420409 100644 --- a/packages/api/remotes/tests/remote-events.host.spec.ts +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -173,10 +173,18 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const agentCtx = ctx.extend() - const agent = { ctx: agentCtx } + const agent = { id: 'agent-1', ctx: agentCtx } const target = scopeTarget(ctx, agent) const request = { questions: [], agent } + await expect(async () => waterfallRaw( + ctx, + target, + 'user-questions/request', + [{ questions: [], agent: { id: 'agent-2', ctx: ctx.extend() } }], + () => Promise.resolve('host fallback'), + )).rejects.toThrow('must carry its Agent directly') + const claimed = waterfallRaw( ctx, target, @@ -188,7 +196,7 @@ describe('Remote event Host source', () => { expect(claimedDispatch).toMatchObject({ event: 'user-questions/request', request, - context: { value: agentCtx, subject: agent }, + context: { value: agentCtx, subject: agent, agentId: 'agent-1' }, }) claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) await expect(claimed).resolves.toBe('client answer') @@ -230,7 +238,7 @@ describe('Remote event Host source', () => { const abort = new AbortController() const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() const delivery = iterator.next() - const agent = { ctx: ctx.extend() } + const agent = { id: 'agent-1', ctx: ctx.extend() } const reason = new Error('forwarded event source removed') const pending = waterfallRaw( ctx, diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 5f3ae8bdd0..91888af26a 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/session-controller/README.md -README.md: 8447cf3192701df87e87ffb67d67425a67554df6 -README.zh.md: 16287feda799d635990a03c40bc5f05a34446a47 +README.md: ef6ffb35636f9f1a87c3b30832f68540d5dcd0ea +README.zh.md: 97949e01379446e6171debb9fb1250e8088413f8 diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 8447cf3192..ef6ffb3563 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -13,6 +13,7 @@ English | [中文](README.zh.md) ## Table of Contents - [Use this package](#use-this-package) +- [Session media references](#session-media-references) - [Configuration](#configuration) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -33,6 +34,12 @@ The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. The echo stores ordered image previews and durable file references. Session derives its `transcript`, `queued`, or `steering` placement from the current running state and requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed, immediately when its identified prompt fails or is abandoned, and as failed on disposal. Each retirement fires `onRetire` exactly once; an observed retirement includes the ordered durable attachment references so the composer can release successful cards while preserving failed drafts. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone. + + +## Session media references + +`SessionMediaReferences` mounts `GET|HEAD /api/file?path=` on the authenticated `connection.fetch` channel when `connection`, `fs`, and `attachments` are composed. It reads ordinary files through `ctx.fs`, including temporary paths outside registered workspaces and files in remote providers. Neither directory containment nor MIME categories restrict access; `mime-types` supplies the response type, with `application/octet-stream` for unknown extensions. GET reuses `readBytes` for preflight and ongoing byte limits; HEAD reads metadata only. All files use `ctx.attachments.imageLimits.maxImageBytes` (normally 20 MiB); exceeding this limit returns 413. Responses contain the complete file, ignore Range, and carry `private, no-store`, `nosniff`, and a sandbox CSP so directly opened HTML/SVG cannot execute with the API origin. The Client rewrite lives in `ui-chat` (`AssistantMarkdown`); audio/video responses are available, while Markdown audio/video player nodes remain separate work. + ----- @@ -59,6 +66,7 @@ No direct effect; model requests remain owned by the Agent and LLM packages. +- The image byte cap does not validate decoded dimensions or pixel count. - Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. - A failed follow resumption remains visible to the caller instead of retrying indefinitely. - The raw browser upload is one streaming HTTP request without resumable offsets; a retry sends the file again from byte zero. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 16287feda7..97949e0137 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -13,6 +13,7 @@ kind: "package-reference" ## 目录 - [使用本包](#use-this-package) +- [会话媒体引用](#session-media-references) - [配置](#configuration) - [模型体验](#model-experience) - [已知限制与延期工作](#known-limitations-and-deferred-work) @@ -33,6 +34,12 @@ Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。回显按顺序存放图片预览与持久文件引用。Session 根据当前运行状态与请求的投递模式推导其 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休。每次退休恰好触发一次 `onRetire`;observed 退休还会携带有序的持久附件引用,让 composer 释放成功卡片并保留失败草稿。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。 + + +## 会话媒体引用 + +当 `connection`、`fs` 与 `attachments` 均被组合时,`SessionMediaReferences` 在鉴权 `connection.fetch` 通道上挂载 `GET|HEAD /api/file?path=<绝对路径>`。它通过 `ctx.fs` 读取普通文件,包括已注册工作区之外的临时路径与远程提供方中的文件。目录包含关系与 MIME 类别均不限制访问;`mime-types` 提供响应类型,未知扩展名使用 `application/octet-stream`。GET 复用 `readBytes` 执行读取前及读取中的字节限制;HEAD 只读取元数据。所有文件均使用 `ctx.attachments.imageLimits.maxImageBytes`(通常为 20 MiB);超过此上限返回 413。响应包含完整文件,忽略 Range,并携带 `private, no-store`、`nosniff` 与 sandbox CSP,使直接打开的 HTML/SVG 无法以 API 源身份执行脚本。客户端重写位于 `ui-chat`(`AssistantMarkdown`);音视频文件响应已可用,Markdown 音视频播放器节点仍是独立工作。 + ----- @@ -59,6 +66,7 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用 +- 图片字节上限不校验解码后的尺寸或像素数。 - Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 - follow 恢复失败会对调用方可见,而不会无限重试。 - 浏览器原始字节上传使用一次不带断点续传偏移的流式 HTTP 请求;重试会从第一个字节重新传输整个文件。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 65b757e0e1..19b5339da6 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-deque": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", + "mime-types": "^3.0.2", "zod": "^4.4.3" }, "peerDependencies": { @@ -84,6 +85,7 @@ "@deepseek-ai/dsh-client-file-upload": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -125,9 +127,11 @@ "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-file-upload": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-jobs": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-native-command": "workspace:^", @@ -150,6 +154,7 @@ "@deepseek-ai/dsh-util-time": "workspace:^", "@deepseek-ai/dsh-util-values": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", - "@deepseek-ai/dsh-workspace": "workspace:^" + "@deepseek-ai/dsh-workspace": "workspace:^", + "@types/mime-types": "^3.0.1" } } diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index b1f1872662..dd41090c22 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -376,12 +376,14 @@ export class ApiSessionAgentController { readonly setup: AgentSetup }> { const presets = this.ctx.get('agentPresets') - if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } + if (presets === undefined) { + return { setup: (_agentCtx, agent) => { this.installSelection(agent) } } + } const resolvedId = (await presets.resolve(presetId)).id return { agentPreset: resolvedId, - setup: async (agentCtx) => { - this.installSelection(agentCtx) + setup: async (agentCtx, agent) => { + this.installSelection(agent) await presets.mount(agentCtx, resolvedId) }, } @@ -490,9 +492,7 @@ export class ApiSessionAgentController { return { provider, model } } - private installSelection(agentCtx: Context): void { - const agent = agentCtx.agent - if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') + private installSelection(agent: Agent): void { this.selectionFor(agent) } diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index c058f25a08..9cc93be179 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -22,6 +22,7 @@ import { ApiSessionList } from './list.ts' import { buildModelCatalog } from './catalog.ts' import { installModelSelectionProjection } from './model-selection-projection.ts' import { SessionSkillCatalog } from './skill-catalog.ts' +import { SessionMediaReferences } from './media-references.ts' import type { ModelCatalog, SessionAttachmentRequest, @@ -134,6 +135,7 @@ export class SessionController extends TypertRemoteService { this.canOpenPath = internals.canOpenPath ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) + ctx.plugin(SessionMediaReferences) ctx.plugin(SessionSkillCatalog) ctx.on('session/created', (session) => { diff --git a/packages/api/session-controller/src/media-references.ts b/packages/api/session-controller/src/media-references.ts new file mode 100644 index 0000000000..9da80029f5 --- /dev/null +++ b/packages/api/session-controller/src/media-references.ts @@ -0,0 +1,77 @@ +/** + * Authenticated GET/HEAD /api/file reads bounded file responses through + * the composed filesystem provider. Paths and MIME types do not restrict access; + * the connection service authenticates requests before this handler. + * @module @deepseek-ai/dsh-api-session-controller/media-references + */ + +import { isAbsolute } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-client-connection' +import type {} from '@deepseek-ai/dsh-attachment' +import { FsError, type FileSystem } from '@deepseek-ai/dsh-fs' +import mime from 'mime-types' + +const BASE_HEADERS = { + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + // HTML and SVG files may be opened directly on the authenticated API origin. + 'Content-Security-Policy': "sandbox; default-src 'none'", +} + +async function serveFile(request: Request, fs: FileSystem, maxBytes: number): Promise { + const fail = (status: number, text: string): Response => + new Response(request.method === 'HEAD' ? null : text, { status, headers: BASE_HEADERS }) + const path = new URL(request.url).searchParams.get('path') + if (path === null || path.length === 0) return fail(400, 'missing path') + if (path.includes('\0') || !isAbsolute(path)) return fail(400, 'absolute path required') + try { + const target = await fs.resolve(path, { signal: request.signal }) + const mediaType = mime.lookup(target.displayPath) || 'application/octet-stream' + const headers: Record = { + ...BASE_HEADERS, + 'Content-Type': mediaType, + } + if (request.method === 'HEAD') { + const info = await fs.stat(target, request.signal) + if (info === undefined) return fail(404, 'not found') + if (info.type !== 'file') return fail(403, 'not a regular file') + if (info.size !== undefined) { + if (info.size > maxBytes) return fail(413, 'file exceeds byte limit') + headers['Content-Length'] = String(info.size) + } + return new Response(null, { headers }) + } + const bytes = await fs.readBytes(target, request.signal, maxBytes) + headers['Content-Length'] = String(bytes.byteLength) + return new Response(bytes.slice(), { headers }) + } catch (error: unknown) { + if (!(error instanceof FsError)) throw error + const statuses: Partial> = { + FS_NOT_FOUND: 404, + FS_NOT_REGULAR_FILE: 403, + FS_PERMISSION_DENIED: 403, + FS_SANDBOX_DENIED: 403, + FS_TOO_LARGE: 413, + FS_ABORTED: 499, + } + return fail(statuses[error.code] ?? 500, error.code) + } +} + +/** + * File-display contribution. The connection service supplies authentication; + * `ctx.fs` supplies the execution world's paths, reads, and access policy. + */ +export const SessionMediaReferences = { + inject: ['connection', 'fs', 'attachments'], + apply(ctx: Context): void { + const maxBytes = ctx.attachments.imageLimits.maxImageBytes + ctx.effect(() => ctx.connection.fetch.register({ + path: '/api/file', + methods: ['GET', 'HEAD'], + requestBody: 'buffered', + fetch: request => serveFile(request, ctx.fs, maxBytes), + }), 'session-controller: /api/file') + }, +} diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts index d5b569aa49..586d7b4e7b 100644 --- a/packages/api/session-controller/tests/agent.host.spec.ts +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -443,7 +443,7 @@ describe('ApiSession create or adoption', () => { .rejects.toBeInstanceOf(ApiSessionCwdConflict) }) - it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { + it('surfaces directory creation failure', async () => { const { agents } = await harness() const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) tempDirs.push(parent) @@ -451,8 +451,5 @@ describe('ApiSession create or adoption', () => { writeFileSync(file, 'not a directory') await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) .rejects.toThrow('failed to ensure project directory') - - const composition = await agents.composeAgent(undefined) - expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent') }) }) diff --git a/packages/api/session-controller/tests/media-references.host.spec.ts b/packages/api/session-controller/tests/media-references.host.spec.ts new file mode 100644 index 0000000000..61572ab0be --- /dev/null +++ b/packages/api/session-controller/tests/media-references.host.spec.ts @@ -0,0 +1,224 @@ +import { appendFile, mkdir, mkdtemp, open, realpath, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' +import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' +import { SessionMediaReferences } from '../src/media-references.ts' + +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]) +const DEFAULT_LIMIT = 20 * 1024 * 1024 + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('SessionMediaReferences /api/file', () => { + let root: string + const contexts: Context[] = [] + + beforeEach(async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-media-references-'))) + }) + + afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await rm(root, { recursive: true, force: true }) + }) + + async function mount(maxBytes = DEFAULT_LIMIT) { + const ctx = new Context() + contexts.push(ctx) + let handler: ((request: Request) => Promise) | undefined + const unregister = vi.fn(() => {}) + ctx.provide('connection', { + fetch: { + register: (registered: { fetch: (request: Request) => Promise }) => { + handler = registered.fetch + return unregister + }, + }, + } as never) + ctx.provide('attachments', { imageLimits: { maxImageBytes: maxBytes } } as never) + await ctx.plugin(LocalFileSystem, { cwd: root }).await() + await ctx.plugin(SessionMediaReferences).await() + const raw = (url: string, init?: RequestInit) => { + if (handler === undefined) throw new Error('route not registered') + return handler(new Request(url, init)) + } + return { + call: (path: string, init?: RequestInit) => raw(`http://127.0.0.1/api/file?path=${encodeURIComponent(path)}`, init), + raw, + fs: ctx.fs as LocalFileSystem, + unregister, + dispose: () => ctx.fiber.dispose(), + } + } + + it('serves the inclusive image cap and refuses larger images for GET, HEAD and Range', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'bounded.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { headers: { range: 'bytes=0-0' } })).status).toBe(413) + const head = await route.call(path, { method: 'HEAD' }) + expect(head.status).toBe(413) + expect(head.body).toBeNull() + }) + + it('rejects a sparse 1 GiB image before content I/O', async () => { + const route = await mount() + const inspect = vi.fn() + route.fs.internals.inspectReadBytesAfterStat = inspect + const path = join(root, 'huge.png') + const handle = await open(path, 'w') + try { + await handle.truncate(1024 * 1024 * 1024) + } finally { + await handle.close() + } + expect((await route.call(path)).status).toBe(413) + expect(inspect).not.toHaveBeenCalled() + }) + + it('uses the filesystem byte reader to reject post-stat image growth', async () => { + const route = await mount(PNG_BYTES.length) + const path = join(root, 'growing.png') + await writeFile(path, PNG_BYTES) + route.fs.internals.inspectReadBytesAfterStat = async () => { + await appendFile(path, new Uint8Array(1)) + } + expect((await route.call(path)).status).toBe(413) + }) + + it.each([ + ['png', 'image/png'], ['svg', 'image/svg+xml'], ['mp4', 'video/mp4'], ['mp3', 'audio/mpeg'], + ['txt', 'text/plain'], ['html', 'text/html'], ['bin', 'application/octet-stream'], ['', 'application/octet-stream'], + ])('serves .%s files with their MIME type and response protections', async (extension, mediaType) => { + const route = await mount() + const path = join(root, `file${extension === '' ? '' : `.${extension}`}`) + await writeFile(path, PNG_BYTES) + const response = await route.call(path) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe(mediaType) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.headers.get('cache-control')).toBe('private, no-store') + expect(response.headers.get('x-content-type-options')).toBe('nosniff') + expect(response.headers.get('content-security-policy')).toBe("sandbox; default-src 'none'") + expect(await responseBytes(response)).toEqual(PNG_BYTES) + }) + + it.each(['mp4', 'mp3', 'bin'])('applies the attachment byte cap to .%s files', async (extension) => { + const route = await mount(PNG_BYTES.length) + const path = join(root, `file.${extension}`) + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + await appendFile(path, new Uint8Array(1)) + expect((await route.call(path)).status).toBe(413) + expect((await route.call(path, { method: 'HEAD' })).status).toBe(413) + }) + + it('ignores Range headers and returns complete bodies without advertising ranges', async () => { + const route = await mount() + const path = join(root, 'clip.mp4') + await writeFile(path, PNG_BYTES) + for (const range of ['bytes=0-3', 'bytes=-4', 'bytes=999-', 'bytes=abc', 'items=0-0', 'bytes=0-1,3-4']) { + const response = await route.call(path, { headers: { range } }) + expect(response.status).toBe(200) + expect(response.headers.get('accept-ranges')).toBeNull() + expect(response.headers.get('content-range')).toBeNull() + expect(await responseBytes(response)).toEqual(PNG_BYTES) + } + }) + + it('answers HEAD without reading content and reports missing and non-regular files', async () => { + const route = await mount() + const path = join(root, 'image.png') + await writeFile(path, PNG_BYTES) + const read = vi.spyOn(route.fs, 'readBytes') + const response = await route.call(path, { method: 'HEAD', headers: { range: 'bytes=0-3' } }) + expect(response.status).toBe(200) + expect(response.headers.get('content-length')).toBe(String(PNG_BYTES.length)) + expect(response.body).toBeNull() + expect(read).not.toHaveBeenCalled() + expect((await route.call(join(root, 'missing'), { method: 'HEAD' })).status).toBe(404) + expect((await route.call(root, { method: 'HEAD' })).status).toBe(403) + vi.spyOn(route.fs, 'stat').mockResolvedValue({ type: 'file', version: FsVersion('v1') }) + expect((await route.call(path, { method: 'HEAD' })).headers.get('content-length')).toBeNull() + }) + + it('rejects malformed paths, absent files, and directories', async () => { + const route = await mount() + expect((await route.raw('http://127.0.0.1/api/file')).status).toBe(400) + for (const path of ['', 'relative.png', '/a\0b.png']) { + expect((await route.call(path)).status).toBe(400) + } + const head = await route.call('', { method: 'HEAD' }) + expect(head.status).toBe(400) + expect(head.body).toBeNull() + expect((await route.call(join(root, 'missing.png'))).status).toBe(404) + await mkdir(join(root, 'frames.png')) + expect((await route.call(join(root, 'frames.png'))).status).toBe(403) + }) + + it('reads files and symlink targets outside the default cwd without a workspace registry', async () => { + const route = await mount() + const outside = await mkdtemp(join(tmpdir(), 'dsh-media-outside-')) + try { + const path = join(outside, 'image.png') + await writeFile(path, PNG_BYTES) + expect(await responseBytes(await route.call(path))).toEqual(PNG_BYTES) + const link = join(root, 'linked.png') + await symlink(path, link) + expect(await responseBytes(await route.call(link))).toEqual(PNG_BYTES) + } finally { + await rm(outside, { recursive: true, force: true }) + } + }) + + it.skipIf(process.platform === 'win32')('rejects a FIFO before opening it', async () => { + const route = await mount() + const path = join(root, 'stream.png') + const { execFile } = await import('node:child_process') + const { promisify } = await import('node:util') + await promisify(execFile)('mkfifo', [path]) + expect((await route.call(path)).status).toBe(403) + }) + + it('reads opaque remote targets through ctx.fs and preserves provider failures', async () => { + const route = await mount() + const target = { targetKey: FsTargetKey('opaque-remote-id'), displayPath: '/remote/photo.png' } + vi.spyOn(route.fs, 'resolve').mockResolvedValue(target) + const read = vi.spyOn(route.fs, 'readBytes').mockResolvedValue(PNG_BYTES) + expect(await responseBytes(await route.call('/remote/photo.png'))).toEqual(PNG_BYTES) + expect(read).toHaveBeenCalledWith(target, expect.any(AbortSignal), DEFAULT_LIMIT) + for (const [code, status] of [ + ['FS_PERMISSION_DENIED', 403], ['FS_SANDBOX_DENIED', 403], ['FS_NOT_FOUND', 404], + ['FS_NOT_REGULAR_FILE', 403], ['FS_TOO_LARGE', 413], ['FS_IO_ERROR', 500], + ] as const) { + read.mockRejectedValueOnce(new FsError('provider rejected read', code)) + expect((await route.call('/remote/photo.png')).status).toBe(status) + } + read.mockRejectedValueOnce(new Error('provider bug')) + await expect(route.call('/remote/photo.png')).rejects.toThrow('provider bug') + }) + + it('serves an empty file and respects an aborted request', async () => { + const route = await mount() + const path = join(root, 'empty.png') + await writeFile(path, '') + const response = await route.call(path) + expect(response.headers.get('content-length')).toBe('0') + expect(await response.text()).toBe('') + expect((await route.call(path, { signal: AbortSignal.abort() })).status).toBe(499) + }) + + it('unregisters the route on disposal', async () => { + const route = await mount() + await route.dispose() + expect(route.unregister).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts index e029ae16c1..1661f505bc 100644 --- a/packages/api/session-controller/tests/session-fork.host.spec.ts +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -37,9 +37,9 @@ async function composed(workspaces: readonly Workspace[] = []): Promise : { inheritedEventCount: options.inheritedEventCount }, }) const agent = {} as Agent - const agentCtx = ownerCtx.extend({ agent }) + const agentCtx = ownerCtx Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) - await options.setup?.(agentCtx) + await options.setup?.(agentCtx, agent) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } }, diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts index 0b6e872653..d917ccb777 100644 --- a/packages/api/session-controller/tests/session-presets.host.spec.ts +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -66,9 +66,8 @@ async function harness(presets?: readonly string[]) { options.meta === undefined ? {} : { meta: options.meta }, ) const agent = stubAgent(session) - const agentCtx = ctx.extend({ agent }) - ;(agent as { ctx?: Context }).ctx = agentCtx - await options.setup?.(agentCtx) + ;(agent as { ctx?: Context }).ctx = ctx + await options.setup?.(ctx, agent) const unregister = ctx.agents.register(agent) return { agent, dispose: () => { unregister(); return Promise.resolve() } } }, diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 85366bd632..45a22e93cb 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -17,6 +17,7 @@ "src/file-references.ts", "src/history.ts", "src/list.ts", + "src/media-references.ts", "src/model-selection-projection.ts", "src/skill-catalog.ts" ], @@ -30,8 +31,10 @@ { "path": "../../context/file-reference" }, { "path": "../../attachment/attachment" }, { "path": "../../client/file-upload/tsconfig.host.json" }, + { "path": "../../client/connection/tsconfig.host.json" }, { "path": "../../interaction/permission-presets" }, { "path": "../../jobs/jobs" }, + { "path": "../../fs/fs" }, { "path": "../../llm/llm" }, { "path": "../../util/deque" }, { "path": "../../util/native-command" }, diff --git a/packages/api/workspace-files/README.i18n.yaml b/packages/api/workspace-files/README.i18n.yaml index 80c6207706..3d4b3525e8 100644 --- a/packages/api/workspace-files/README.i18n.yaml +++ b/packages/api/workspace-files/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/api/workspace-files/README.md -README.md: f7442845bc3c592bee0c59817a72ad07c8c91a2a -README.zh.md: 4acd022335ee7c276aca00d66e177c19b060df0a +README.md: ec15f52bf17fffca2b225fa427a7405922aaf2b3 +README.zh.md: 7e45d5c9a6073435e26e1237791a328db654d839 diff --git a/packages/api/workspace-files/README.md b/packages/api/workspace-files/README.md index f7442845bc..ec15f52bf1 100644 --- a/packages/api/workspace-files/README.md +++ b/packages/api/workspace-files/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`@deepseek-ai/dsh-api-workspace-files` owns the Host `ctx.workspaceFiles` service and the generated Client `workspaceFiles` Remote namespace: `read` returns one page of lines from a UTF-8 text file, `readBytes` returns one window of raw bytes from any regular file, `stat` returns a file's version and size without its content, `list` returns one directory's direct children, and `changes` streams every filesystem observation an Agent makes inside the Session's workspace root. All five run over the composed `ctx.fs` and confine themselves to the workspace root the sandbox policy resolves for the addressed Session; the filesystem backend's own cwd never decides. Client packages reach the namespace through the [`api-remotes`](../../api/remotes/README.md) assembly. The package's `./client` export registers the `file` resource provider that turns `stat` and `changes` into live file metadata for `useResource<'file'>`; the Sidebar's file tree tab lists directories through `list`. +Use this package to browse and inspect files within a Session's workspace from the web client. It reads UTF-8 text one page of lines at a time, reads raw bytes in bounded windows, reports file versions and sizes, lists direct directory children, and streams changes caused by Agent file operations. Every operation stays within the workspace root selected for the addressed Session, independent of the filesystem backend's working directory. Client components can also follow live file metadata and build the Sidebar file tree through the shared Remote API. ## Table of Contents diff --git a/packages/api/workspace-files/README.zh.md b/packages/api/workspace-files/README.zh.md index 4acd022335..7e45d5c9a6 100644 --- a/packages/api/workspace-files/README.zh.md +++ b/packages/api/workspace-files/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`@deepseek-ai/dsh-api-workspace-files` 拥有 Host 侧 `ctx.workspaceFiles` 服务与生成的 Client 侧 `workspaceFiles` Remote 命名空间:`read` 返回一个 UTF-8 文本文件的一页行,`readBytes` 返回任意普通文件的一个原始字节窗口,`stat` 返回文件的版本与大小而不带内容,`list` 返回一个目录的直接子项,`changes` 流式推送 Agent 在 Session 工作区根内做出的每一次文件系统观察。五者都经组合后的 `ctx.fs` 运行,并把自己限定在沙箱策略为被寻址 Session 解析出的工作区根内;文件系统后端自己的 cwd 从不参与判定。Client 包经 [`api-remotes`](../../api/remotes/README.zh.md) 装配触达该命名空间。本包的 `./client` 导出注册 `file` 资源提供者,把 `stat` 与 `changes` 变成 `useResource<'file'>` 的实时文件元数据;Sidebar 的文件树 tab 经 `list` 列举目录。 +使用本包可从 Web Client 浏览和检查 Session 工作区内的文件。它按行分页读取 UTF-8 文本、按有界窗口读取原始字节、报告文件版本与大小、列举目录的直接子项,并流式推送 Agent 文件操作造成的变更。每项操作都限定在为被寻址 Session 选择的工作区根内,不受文件系统后端工作目录影响。Client 组件还可经共享 Remote API 跟随实时文件元数据并构建 Sidebar 文件树。 ## 目录 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 9c5f3a1abb..f6a61238ee 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: 364153b7b56daa725003178b6cfad90e3f94bc04 -README.zh.md: 6ca5c6df8289c9e16bfe608b5b9ae200adf18a6b +README.md: d4b8037c5e5cdcd9cd39302422d74ef854fb0890 +README.zh.md: 37d03f5edda1311f51968fe66f928cb19886514c diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index 364153b7b5..d4b8037c5e 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately, and generic files are saved byte-for-byte with no admission limits. Streamed file writes and reads use bounded chunks; writes hash into a private staging object before atomic publication, and reads verify the recorded byte length and digest without a whole-file memory copy. It is what the shipped `dsh` composition uses, so durable attachments work without configuration. Identical bytes occupy one canonical object even when uploads use different display names; each model-facing name is a hard link to that object. Concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine; other hosts cannot read these objects, and objects are never deleted automatically. +Store images and generic file attachments durably below `DSH_HOME` on the machine running DSH. Images are validated, normalized for model requests, and cached per route; generic files are preserved byte-for-byte without admission limits. Identical bytes are stored once even when uploads use different display names, reads verify file length and content, and admitted images remain readable if limits later tighten. The shipped `dsh` composition uses this package without configuration. Objects remain local to one machine and are never deleted automatically. ## Table of Contents diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 6ca5c6df82..37d03f5edd 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存,通用文件则不设准入限制,按字节原样保存。流式文件写入与读取都使用有界分块;写入会在私有暂存对象中计算摘要后原子发布,读取会校验记录的字节长度与摘要,两者都不产生整文件内存副本。随附的 `dsh` 组合使用的就是它,因此持久附件无需配置即可工作。即使使用不同显示名称上传,相同字节也只占用一个规范对象;每条模型可见路径都是指向该对象的硬链接。同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机,其他主机无法读取这些对象,对象也永远不会自动删除。 +在运行 DSH 的机器上,把图片与通用文件附件持久存储到 `DSH_HOME` 下。图片经过校验、针对模型请求完成规范化并按路由缓存;通用文件不设准入限制,按字节原样保存。即使上传时使用不同显示名称,相同字节也只存储一次;读取会校验文件长度与内容,之后收紧限制也不会让已接纳的图片不可读。随附的 `dsh` 组合无需配置即可使用本包。对象仅限本机,并且永远不会自动删除。 ## 目录 diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index d92fc02572..9924b1e0d2 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: a812182aeff6d09506a1ea2d4fa8d9a44a175936 -README.zh.md: c487f8204c86d8f0bbdfd85280e8fbab6ea14dec +README.md: fc3903cb1ab4ed4a1249ad2ec62c0df633f4a7c4 +README.zh.md: 9e84a5ed8889d541b3cb87fb5e5d5560d36a2bb5 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index a812182aef..fc3903cb1a 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -You can attach images and generic files to prompts, and the harness keeps them durably: each source image is admitted and normalized before your message is processed, while any other file is stored byte-for-byte with no format or size limits, and both reappear in conversation history across restarts of the same session. The shipped `dsh` composition enables this with no setup. Browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Images accept raster formats (PNG, JPEG, WebP, GIF) under deployment limits; files accept anything, and the model reads a stored file on demand from its saved read-only path instead of receiving its bytes. Stored objects are never deleted automatically, and audio and video have no dedicated handling yet. +Attach images and generic files to prompts and commands, then reuse them after restarting the same session, without extra setup in the shipped `dsh` composition. Images are validated and normalized before the message is accepted; PNG, JPEG, WebP, and GIF are supported within deployment limits. Other files are stored byte-for-byte without format or size limits, and models read them on demand through saved read-only paths instead of receiving their bytes. Durable session events exclude browser paths, provider URLs, local storage paths, and base64. Stored attachments are never deleted automatically; audio and video have no dedicated handling. ## Table of Contents diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index c487f8204c..9e84a5ed88 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -你可以把图片和通用文件附加到提示词中,harness 会持久保存它们:每张源图都会在你的消息被处理前准入并规范化,而其他任何文件都按字节原样保存、不设格式与大小限制,两者都会在同一会话重启后重新出现在对话历史中。随附的 `dsh` 组合无需任何配置即可支持这一点。浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。图片接受部署限额内的光栅格式(PNG、JPEG、WebP、GIF);文件接受任何内容,模型不接收文件字节,而是在需要时从保存的只读路径按需读取。已存储对象永远不会被自动删除,音频和视频暂无专门处理。 +把图片与通用文件附加到提示词和命令中,同一会话重启后仍可复用;随附的 `dsh` 组合无需额外配置。图片会在消息被接受前完成校验与规范化;部署限额内支持 PNG、JPEG、WebP 和 GIF。其他文件按字节原样保存,不设格式与大小限制;模型通过保存的只读路径按需读取,而不接收文件字节。持久会话事件不包含浏览器路径、提供方 URL、本地存储路径和 base64。已存储附件不会被自动删除;音频和视频暂无专门处理。 ## 目录 diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 9e3e8f5bc5..e119e03f59 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: fff0ba4df85b7ea834a79087ecbfe9f1e27f7714 -README.zh.md: 345db4a86ed2088a998c1723c3f906c614a171f3 +README.md: f0c6636b342d856b44c9d5eaffbbd4657f3e88ae +README.zh.md: 31246ada165bad830bf2f5808fe9c6e1ad91d7cb diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index fff0ba4df8..f0c6636b34 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-cmdline` lets your app own its command line: the launcher keeps only its own flags (`--profile`, `--patch`, the config dumps) and passes everything after them to your app verbatim, so your app decides its flags, its `--help` text, and its parse errors. Values you parse from those arguments win over any default written in the config, without writing anything back. Your app also gets a bounded way to ask for process exit, wired to the launcher's shutdown. Use it when you write an app bin that accepts its own flags; it adds no prompt, schema, or model-facing surface of its own. +`dsh-cmdline` lets an app parse its own flags, `--help`, and errors from the arguments left unchanged after launcher flags. Parsed values can override configuration defaults without rewriting configuration. The app can also request process exit through the launcher's shutdown path. Use this package for app bins with their own command-line interface. It adds no prompt, schema, or model-visible content. ## Table of Contents diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 345db4a86e..31246ada16 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -`dsh-cmdline` 让你的应用持有自己的命令行:启动器只保留属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给你的应用,因此 flag、`--help` 文本与解析错误都由你的应用决定。你从这些参数解析出的值会胜过配置中写下的任何默认值,且无需写回任何内容。你的应用还获得一个有边界的进程退出请求,接到启动器的关停上。当你编写接受自有 flag 的应用 bin 时使用它;它本身不增加任何提示词、schema 或面向模型的表面。 +`dsh-cmdline` 让应用从启动器 flag 之后原样留下的参数中解析自己的 flag、`--help` 与错误。解析值可以覆盖配置默认值,而无需改写配置。应用还可以通过启动器的关停路径请求进程退出。适用于拥有自有命令行界面的应用 bin。它不增加提示词、schema 或模型可见内容。 ## 目录 diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index e590b69b83..6f583fad95 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -112,11 +112,7 @@ async function bench(script: Script): Promise<{ inject: () => {}, whenIdle: () => idle, } - const agentCtx = ownerCtx.extend({ agent }) - Object.assign(agent, { - ctx: agentCtx, - }) - await options.setup?.(agentCtx) + await options.setup?.(ownerCtx, agent) script.before?.(session) ctx.agents.register(agent) return { agent, dispose: () => Promise.resolve() } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index e5b2cc3b72..e04aaa88d7 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 0f71be178c25c0e6687a6e51ff777a9d6ac76a5a -README.zh.md: ea7747c0b814dc36d222d0d7445732159f589d7b +README.md: aea694942173a856861d00a69f15b451cc930976 +README.zh.md: f1b402c985ec2c21afdd67f8cb5477196aafeb44 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 0f71be178c..aea6949421 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -Run `dsh --profile web` and the interface opens in your default browser, ready for interactive chat with the agent. You get the conversation view, model and settings management, and session history, backed by the same model access, tools, and safety defaults as every other surface. The command prints a tokenized startup URL; the browser exchanges that token for a signed session cookie and redirects to the clean root URL. You can change the port, suppress the browser handoff, and allow extra hosts from the command line; binding all network interfaces is intentionally not supported. Choose it for interactive work in the browser; `dsh-headless` is the one-shot command-line sibling. +Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks. ## Table of Contents diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index ea7747c0b8..f1b402c985 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -9,7 +9,7 @@ kind: "package-bundle" ## 概述 -运行 `dsh --profile web`,界面会在你的默认浏览器中打开,即可与 agent(智能体)交互式聊天。你会获得会话视图、模型与设置管理以及会话历史,背后与其他表层相同的模型访问、工具与安全默认值。该命令会打印带 token 的启动 URL;浏览器用该 token 换取签名会话 cookie,再重定向到干净的根 URL。你可以从命令行更改端口、关闭浏览器交接并允许额外主机;有意不支持绑定所有网络接口。需要浏览器中的交互式工作时选择它;`dsh-headless` 是一次性的命令行兄弟表层。 +运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印经过认证的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。 ## 目录 diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 4f0367a44f..63edf46529 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -21,7 +21,7 @@ import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' -import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { launchedThroughSsh, launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' @@ -81,15 +81,6 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' -/** Whether this process was launched through SSH, including a forwarded-port session. */ -function launchedThroughSsh(ctx: Context): boolean { - const environment = launchEnvironmentOf(ctx) - return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { - const value = environment.getFrom(name, ['process'])?.value - return value !== undefined && value !== '' - }) -} - const BROWSER_OPENER_MODULE = import.meta.resolve('open') const BROWSER_OPENER_PROGRAM = ` @@ -235,7 +226,7 @@ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) // The loopback URL belongs to this host. Under SSH, the operator reaches it // through a local forwarding address that this process cannot derive. - const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx) + const handoffBrowser = config.openBrowser && !launchedThroughSsh(launchEnvironmentOf(ctx)) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 85dc4fcffd..c23797ba1f 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: aec7edcb1e15d174544a9abaf99a4dc784034f2a -README.zh.md: e4f069e1afef4973ebc8fdcc507a720c7a02be79 +README.md: 6bb433b8411fa9db3d6de981a24895e3c7b674c4 +README.zh.md: c04292b86becba404e5dbb58ca924833ee88f58b diff --git a/packages/client/README.md b/packages/client/README.md index aec7edcb1e..6bb433b841 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -The `client/` group runs the browser half of the dsh web GUI: it boots the web shell, loads browser-side plugin modules, keeps browser-to-host RPC and event delivery alive, and provides the shared client services and UI feature plugins that render the application. UI features compose through the slot system — each plugin fills declared extension slots with typed props and stores, and the shell renders the assembled tree. All packages here are product packages named `@deepseek-ai/dsh-client-`; the host half that serves the page lives in [`host/`](../host/README.md). Authoring rules live in [AGENTS.md](AGENTS.md), and the module graph, slot model, and object layer are documented in the related notes below. +The `client/` group provides the browser experience for the dsh web GUI, including conversation, navigation, settings, approvals, file access, and other interactive features. Choose packages from this family when adding browser-visible behavior; use [`host/`](../host/README.md) for server-side page delivery and host integration. Packages cover both the shared browser foundation and focused UI features, while each child README owns its configuration and behavior. Authoring rules live in [AGENTS.md](AGENTS.md), and the related documentation below explains cross-package composition. ## Table of Contents diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index e4f069e1af..c04292b86b 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -9,7 +9,7 @@ kind: "package-group" ## 概述 -`client/` 组运行 dsh web GUI 的浏览器侧:它启动 web 外壳、加载浏览器侧插件模块、维持浏览器与宿主之间的 RPC 与事件投递,并提供渲染应用所需的共享客户端服务与 UI 功能插件。UI 功能通过 slot 系统组合——每个插件填充已声明的扩展 slot,携带类型化 props 与 store,由外壳渲染组装后的整棵树。本组所有包均为产品包,名为 `@deepseek-ai/dsh-client-`;服务于页面的宿主半侧位于 [`host/`](../host/README.zh.md)。编写规则见 [AGENTS.md](AGENTS.md),模块图、slot 模型与对象层的说明见下方相关文档。 +`client/` 组提供 dsh web GUI 的浏览器体验,包括对话、导航、设置、批准、文件访问及其他交互功能。添加浏览器中可见的行为时,请选择本系列中的包;服务端页面交付与宿主集成则使用 [`host/`](../host/README.zh.md)。本系列同时涵盖共享浏览器基础与专门的 UI 功能,各子包 README 拥有其配置与行为说明。编写规则见 [AGENTS.md](AGENTS.md),下方相关文档解释跨包组合方式。 ## 目录 diff --git a/packages/client/locale/README.i18n.yaml b/packages/client/locale/README.i18n.yaml index a159c6cc4f..518649ba23 100644 --- a/packages/client/locale/README.i18n.yaml +++ b/packages/client/locale/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/locale/README.md -README.md: da0931ff5cf78b16d57354a8ac6abe6bf1878e50 -README.zh.md: 18eb233e80ba8a68621b2fa34442cdda3c4329a0 +README.md: 56a9cff9c3ec18dcc395378fcd1691207c022284 +README.zh.md: b23a1f2da59d0d83213d9c38e7cee05843881274 diff --git a/packages/client/locale/README.md b/packages/client/locale/README.md index da0931ff5c..56a9cff9c3 100644 --- a/packages/client/locale/README.md +++ b/packages/client/locale/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -`dsh-client-locale` localizes the web GUI: users choose from the registered languages in Settings → General, and the UI copy switches immediately. The package ships `zh` and `en`, while external client plugins can add languages and their namespace dictionaries. On a loopback page, the choice persists as `locale.preference` in `$DSH_HOME/settings.yaml`; a non-loopback page keeps its selection process-local even though Connection authenticates every API method. A fresh browser starts provisionally in the first registered language requested by `navigator` until an allowed Host preference arrives and replaces it live. Plugin authors receive full type checking for the built-in dictionary form and translate through the framework `t` seat; copy rendered through slots follows language switches without a reload. +Use `dsh-client-locale` to switch the web GUI between the shipped English and Chinese locales or languages added by client plugins. User selections take effect immediately; loopback pages persist them in `$DSH_HOME/settings.yaml`, while non-loopback pages keep them only for the current process. New browsers use the first supported language requested by the browser until an allowed stored preference arrives. Plugin authors add typed namespace dictionaries and translate through the public locale API; slot-rendered copy updates without a reload. ## Table of Contents diff --git a/packages/client/locale/README.zh.md b/packages/client/locale/README.zh.md index 18eb233e80..b23a1f2da5 100644 --- a/packages/client/locale/README.zh.md +++ b/packages/client/locale/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -`dsh-client-locale` 为 web GUI 提供本地化:用户在“设置 → 常规”中从已注册语言中选择,UI 文案会立即切换。本包内置 `zh` 与 `en`,外部 client 插件可以增加语言及其命名空间字典。在 loopback 页面上,该选择以 `locale.preference` 存储在 `$DSH_HOME/settings.yaml` 中;非 loopback 页面即使由 Connection 认证所有 API 方法,也只在进程内保留选择。全新浏览器会先临时使用 `navigator` 请求的第一个已注册语言,直到允许读取的 Host 偏好到达并实时替换。插件作者使用内置字典形式时会获得完整类型检查,并通过框架 `t` 席位翻译;经 slot 渲染的文案会随语言切换即时更新。 +使用 `dsh-client-locale` 可在 web GUI 中切换内置的 English、中文 locale,或 client 插件添加的语言。用户选择会立即生效;loopback 页面把选择持久化到 `$DSH_HOME/settings.yaml`,非 loopback 页面则只为当前进程保留选择。全新浏览器会使用浏览器请求的第一个受支持语言,直到允许读取的已存储偏好到达。插件作者可添加类型化命名空间字典,并通过公开 locale API 翻译;经 slot 渲染的文案无需重新加载即可随语言切换更新。 ## 目录 diff --git a/packages/client/resources/README.i18n.yaml b/packages/client/resources/README.i18n.yaml index 50ca50a65e..34a20c84f9 100644 --- a/packages/client/resources/README.i18n.yaml +++ b/packages/client/resources/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/resources/README.md -README.md: 2bc3d03bc5c58d45f9a0955aa73185be87b2bc6c -README.zh.md: 43238fd5ff3794204f8d6d989e5d771131b8c489 +README.md: 7c32293c8d4250b11f8beaaf473a62145c915bce +README.zh.md: b53089a2449f379f43156f610b587656a998ece3 diff --git a/packages/client/resources/README.md b/packages/client/resources/README.md index 2bc3d03bc5..7c32293c8d 100644 --- a/packages/client/resources/README.md +++ b/packages/client/resources/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The resource model of the web client. A resource is one address, and a resource address is a `dsh-resource:///…` URL whose host is the protocol key; the protocol's owning client package registers a provider that turns an address into a value stream, and any slot component reads that stream through the `useResource` global standard hook. A protocol that needs a scope encodes it in the path (`dsh-resource://file/session//`); the model knows only addresses, and an address under any other scheme (`sidebar://guide`) names no resource. Use it when a component needs live data it only knows by address (a tab record, a link, a mention) and the data's owner is another client plugin. +Use client resources when a component knows live data only by URL address, such as a tab record, link, or mention, while another client package owns the data. Resource addresses use `dsh-resource:///…`; protocols that need a scope encode it in the path. Components receive the current value and later updates through the public `useResource` hook. Unsupported protocols and non-resource schemes, such as `sidebar://guide`, resolve to no resource. ## Table of Contents diff --git a/packages/client/resources/README.zh.md b/packages/client/resources/README.zh.md index 43238fd5ff..b53089a244 100644 --- a/packages/client/resources/README.zh.md +++ b/packages/client/resources/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Web 客户端的资源模型。一份资源是一个地址,资源地址是 `dsh-resource:///…` 形式的 URL,host 即协议键;协议所属的客户端包注册一个提供方把地址变成值的流,任何 slot 组件通过 `useResource` 全局标准 hook 读取这条流。需要作用域的协议把它编进路径(`dsh-resource://file/session//<绝对路径>`);模型本身只认地址,其它 scheme 的地址(`sidebar://guide`)不指向资源。当组件需要的活数据只以地址形式可知(tab 记录、链接、提及),而数据的拥有者是另一个客户端插件时,请使用它。 +当组件只知道活数据的 URL 地址,而数据由另一个客户端包拥有时,请使用客户端资源;例如 tab 记录、链接或提及。资源地址使用 `dsh-resource:///…`;需要作用域的协议把作用域编进路径。组件通过公开的 `useResource` hook 接收当前值与后续更新。不支持的协议与非资源 scheme(例如 `sidebar://guide`)不指向任何资源。 ## 目录 diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 4bfd6aaaf7..a1aa8931e9 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 06f2bc703633069a40b4677d4d84c12f4cc2444c -README.zh.md: a9fecb68fd4cddd192a02667e58133e696e345c8 +README.md: df5a46ae7d1668483b60538cffc4fc1b851163bc +README.zh.md: b83957ea7d1e8e79ec72070505ed24cacfb01864 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 06f2bc7036..df5a46ae7d 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package provides the agent-preset surfaces of the Web GUI: a chip on the new-session screen choosing the next session's preset, a read-only label in the session header, and a settings section that manages the roster — copy, delete, default, and the way into a preset's own files. A session's preset is fixed at creation, so the choice applies to sessions started afterwards while running sessions keep the composition they began with; the default preset is edited in the settings section, where the roster is visible, so General settings carries no duplicate control for the same field. When a deployment composes no presets, all three surfaces render nothing and every session shares the host composition. +Use this package to choose the agent preset for a new Web GUI session, see the active preset in the session header, and manage available presets in Settings. A preset is fixed when a session is created, so changing the selection or default affects only later sessions. If the deployment provides no presets, these controls stay hidden and every session uses the host composition. ## Table of Contents diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index a9fecb68fd..b83957ea7d 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包提供 Web GUI 的 agent preset 表面:新建会话界面的一枚 chip,选择下一个会话的 preset;会话标题旁的一个只读标签;以及一个设置分区,用于管理名单——复制、删除、默认值,以及通往 preset 自身文件的入口。会话的 preset 在创建时即固定,因此选择作用于此后开启的会话,运行中的会话保持它们开始时的组装;默认 preset 在能看到名单的设置分区里编辑,通用设置不再为同一字段保留重复控件。当部署未组装任何 preset 时,三个表面都不渲染任何内容,每个会话共用宿主组装。 +使用本包可以为新的 Web GUI 会话选择 agent preset、在会话标题中查看当前 preset,并在设置中管理可用 preset。preset 在会话创建时即固定,因此更改选择或默认值只影响此后创建的会话。如果部署未提供任何 preset,这些控件保持隐藏,每个会话都使用宿主组装。 ## 目录 diff --git a/packages/client/ui-brand-official/README.i18n.yaml b/packages/client/ui-brand-official/README.i18n.yaml index c74b4ca3e5..65850053e8 100644 --- a/packages/client/ui-brand-official/README.i18n.yaml +++ b/packages/client/ui-brand-official/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-brand-official/README.md -README.md: 0176d78feac7eafa3a99a570a515ad1d753fd686 -README.zh.md: 0879e25fffce4973c4b741ddcdb5fa0e6a6ebbdb +README.md: f8687047ca3b2a88d4fb2ae36a27819852df5ee9 +README.zh.md: 94477d966defce6ec4c3f4536ecdb7ae96389a31 diff --git a/packages/client/ui-brand-official/README.md b/packages/client/ui-brand-official/README.md index 0176d78fea..f8687047ca 100644 --- a/packages/client/ui-brand-official/README.md +++ b/packages/client/ui-brand-official/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -This package fills the sidebar brand slots — `sidebar.brand.mark` and `sidebar.brand.name` — with the official DeepSeek Harness mark and name. It registers these occupants only when the client bundle builds with the `official` profile; every other build loads the plugin but registers nothing, so the shell fallbacks stay visible. The conversation hero slot (`conversation.hero.brand.mark`) stays unoccupied in every build: its declaring package renders the animated hero fish (hover swim morph) as the fallback, and the official brand is that fish. Choose this package when the deployed identity is DeepSeek's own; a deployment with its own brand composes a different package into the same slots instead. It retains no runtime state and contributes nothing to model requests. +This package gives an `official` client build the DeepSeek Harness mark and name in the sidebar. Other build profiles keep the shell's fish mark and local-build label, while the conversation hero always uses the animated fish. Choose it for deployments branded as DeepSeek Harness; deployments with another identity should provide a replacement brand package. It has no runtime state and does not affect model requests. ## Table of Contents diff --git a/packages/client/ui-brand-official/README.zh.md b/packages/client/ui-brand-official/README.zh.md index 0879e25fff..94477d966d 100644 --- a/packages/client/ui-brand-official/README.zh.md +++ b/packages/client/ui-brand-official/README.zh.md @@ -9,7 +9,7 @@ kind: "package-reference" ## 概述 -本包向侧栏品牌槽位——`sidebar.brand.mark` 与 `sidebar.brand.name`——填充官方 DeepSeek Harness 标志与名称。它只在客户端以 `official` profile 构建时注册这些填充;其余构建同样加载插件但不注册任何内容,因此外壳回退保持可见。会话首屏槽位(`conversation.hero.brand.mark`)在所有构建中都保持无填充:其声明包以动画首屏鱼(悬停游动形变)作为回退渲染,而官方品牌正是这条鱼。当部署身份就是 DeepSeek 自身时选择本包;自有品牌的部署改为在相同槽位中组合另一个包。它不保留任何运行时状态,也不向模型请求贡献任何内容。 +本包让以 `official` profile 构建的客户端在侧栏显示 DeepSeek Harness 标志与名称。其他构建 profile 保留外壳的鱼形标志与本地构建标签,会话首屏则始终使用动画鱼。品牌为 DeepSeek Harness 的部署应选择本包;使用其他品牌的部署应提供替代品牌包。本包不保留运行时状态,也不影响模型请求。 ## 目录 diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 9c9c677a23..4985ac3796 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 8a8c02074146eee2ddd86f78d9be7f4756b5d5fc -README.zh.md: a1881d626984c9fb4ee4b2a96d819febe28626a7 +README.md: b5a837f927b483a1f72e8282032fce29683bc237 +README.zh.md: f6b8018434251e46875bfd5cc0b0a242cdf2a439 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 8a8c020741..b5a837f927 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -8,7 +8,7 @@ English | [中文](README.zh.md) ## Summary -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). Its Assistant and Turn Tail definitions fold packed historical Assistant runs without expanding their members. Steering classification retains only next-step Inbox IDs through persistent splice state; next-turn splices create no Chat Context. Local submission echoes (`SessionSnapshot.pendingSubmissions`) retain the surface selected when the submit begins: transcript echoes render at the flow tail, steering echoes render with the pending-steering marker, and queued echoes stay out of Chat. Each echo is hidden per render once a user/steering node or queue occurrence carries its prompt `rpcId`, so the handoff is atomic. +Use this package to render a browser chat from recorded Session conversations, including historical images, localized actions, and restored scroll position. Compact display folds completed-turn process rows while keeping the final answer and independently useful context visible; packed historical Assistant runs remain collapsed. Local transcript and steering submissions appear immediately, remain in their original surface, and disappear atomically when authoritative Session records arrive, while queued submissions stay outside Chat. The package does not assemble or modify model requests. ## Table of Contents diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index a1881d6269..f6b8018434 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -8,7 +8,7 @@ kind: "package-reference" ## 概述 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。其中 Assistant 与 Turn Tail definition 会直接 fold packed Assistant 历史 run,不展开其成员。steering 分类通过持久 splice state 只保留 next-step Inbox ID;next-turn splice 不创建 Chat Context。本地提交回显(`SessionSnapshot.pendingSubmissions`)保留提交开始时选定的区域:transcript 回显位于消息流末尾,steering 回显带 pending-steering 标记,queued 回显不进入 Chat。一旦 user/steering 节点或 queue occurrence 携带回显的 prompt `rpcId`,该回显即在同一渲染中隐藏,因此交接是原子的。 +使用本包可在浏览器中渲染已记录的 Session 对话,包括历史图片、本地化操作和滚动位置恢复。紧凑显示会收起已完成轮次的过程行,同时保持最终答案和独立有用的上下文可见;已打包的历史 Assistant 连续消息保持收起。本地 transcript 与 steering 提交会立即显示并保留在原区域,在权威 Session 记录到达时原子地消失,而 queued 提交始终不进入 Chat。本包不组装或修改模型请求。 ## 目录 diff --git a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx index c517f9d0c2..3212801e00 100644 --- a/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-chat/src/client/chat/AssistantMarkdown.tsx @@ -1,7 +1,7 @@ import { Fragment, memo, useMemo } from 'react' import type { ReactNode } from 'react' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { MarkdownFileMentions, MarkdownPathImages } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeOwnerProps, ChatViewSlotProps } from '../contract/slots.ts' import type { AssistantBlock } from '../contract/snapshot.ts' import { markdownLabels } from '../markdown-labels.ts' @@ -9,6 +9,22 @@ import { ReasoningRow } from './ReasoningRow.tsx' import { useSearchableHidden } from './searchable-hidden.ts' import css from './AssistantMarkdown.module.css' +/** + * Map one authored media destination to the same-origin workspace-file URL. + * @param protocol - `window.location.protocol` at render time. + * @param origin - `window.location.origin` at render time. + * @param value - The authored markdown destination, exactly as written. + * @returns The API URL for an absolute POSIX path on an HTTP(S) page, or + * undefined when the destination cannot be a Host-served local file + * (non-HTTP transport such as Electron `file://`, protocol-relative or + * relative destinations). + */ +export function localPathMediaUrl(protocol: string, origin: string, value: string): string | undefined { + if (protocol !== 'http:' && protocol !== 'https:') return undefined + if (value.length === 0 || !value.startsWith('/') || value.startsWith('//')) return undefined + return `${origin}/api/file?path=${encodeURIComponent(value)}` +} + export interface AssistantMarkdownProps { blocks: readonly AssistantBlock[] streaming: boolean @@ -34,6 +50,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const labels = useMemo(() => markdownLabels(t), [t]) + // Local media paths in the closing prose rewrite to the same-origin file + // API (policy re-validation lives host-side). The vocabulary identity is + // stable per page load because MarkdownText memoizes on it. + const pathImages = useMemo(() => { + const { protocol, origin } = window.location + return { resolve: value => localPathMediaUrl(protocol, origin, value) } + }, []) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -55,6 +78,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ streaming={streaming} labels={labels} fileMentions={mentions} + pathImages={pathImages} />, ) break diff --git a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx index 81b10a7c49..bc7859b364 100644 --- a/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx +++ b/packages/client/ui-chat/src/client/chat/ReasoningRow.tsx @@ -17,7 +17,9 @@ function latestLine(text: string): string { } /** - * Render one assistant reasoning block as the Think disclosure row. + * Render one assistant reasoning block as the Think disclosure row. The + * collapsed summary omits double-asterisk markers; expanded content preserves + * the complete text. * @param props.text - complete or streaming reasoning text. * @param props.running - whether this block is the streaming tail. * @param props.t - conversation locale seat for the running status. @@ -25,7 +27,7 @@ function latestLine(text: string): string { */ export function ReasoningRow({ text, running, t }: { text: string; running: boolean; t: ChatViewSlotProps['t'] }) { const [expanded, setExpanded] = useState(false) - const summary = running ? latestLine(text) : firstLine(text) + const summary = (running ? latestLine(text) : firstLine(text)).replaceAll('**', '') return (
'label') as unknown as ChatViewSlotProps['t'] +const renderMessageImages = (() => null) as unknown as ChatNodeOwnerProps['renderMessageImages'] + +function textBlock(text: string): AssistantBlock { + return { kind: 'text', text } +} + +const ORIGIN = 'http://127.0.0.1:3080' + +describe('localPathMediaUrl', () => { + it('maps an absolute POSIX path on an HTTP page to the file API', () => { + expect(localPathMediaUrl('http:', ORIGIN, '/tmp/graph.png')) + .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + expect(localPathMediaUrl('https:', 'https://127.0.0.1:3080', '/tmp/graph.png')) + .toBe(`https://127.0.0.1:3080/api/file?path=${encodeURIComponent('/tmp/graph.png')}`) + }) + + it('keeps non-HTTP transports inert', () => { + expect(localPathMediaUrl('file:', 'file:///app', '/tmp/graph.png')).toBeUndefined() + expect(localPathMediaUrl('ws:', ORIGIN, '/tmp/graph.png')).toBeUndefined() + }) + + it('keeps destinations that cannot be Host-served local files inert', () => { + expect(localPathMediaUrl('http:', ORIGIN, '')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, '//cdn.example.com/x.png')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, 'relative.png')).toBeUndefined() + expect(localPathMediaUrl('http:', ORIGIN, 'C:\\tmp\\x.png')).toBeUndefined() + }) + + it('encodes the full path including spaces', () => { + expect(localPathMediaUrl('http:', ORIGIN, '/tmp/my graph.png')) + .toBe(`${ORIGIN}/api/file?path=${encodeURIComponent('/tmp/my graph.png')}`) + }) +}) + +describe('AssistantMarkdown local-path images', () => { + it('renders a local image path in closing prose through the same-origin API', () => { + const { container } = render( + , + ) + const image = container.querySelector('img') + expect(image?.getAttribute('alt')).toBe('diagram') + const url = new URL(image?.getAttribute('src') ?? '') + expect(url.pathname).toBe('/api/file') + expect(url.searchParams.get('path')).toBe('/tmp/graph.png') + }) + + it('keeps non-absolute destinations inert', () => { + const { container } = render( + , + ) + expect(container.querySelector('img')).toBeNull() + expect(container.textContent).toContain('diagram') + }) +}) diff --git a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx index 4b32f77119..439b79bc7a 100644 --- a/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx +++ b/packages/client/ui-chat/tests/reasoning-row.client.spec.tsx @@ -70,6 +70,34 @@ describe('ReasoningRow', () => { expect(row.getAttribute('aria-expanded')).toBe('false') }) + it.each([ + { + label: 'settled', + text: '**Comparing checkout and merge bases**\nKeep **reviewing**', + streaming: false, + }, + { + label: 'streaming', + text: 'Inspect the session\n**Comparing checkout and merge bases**', + streaming: true, + }, + ])('strips double-asterisk markers from the $label summary without changing the reasoning body', ({ text, streaming }) => { + const view = render( + , + ) + + expect(view.getByText('Comparing checkout and merge bases')).toBeTruthy() + expect(view.queryByText('**Comparing checkout and merge bases**')).toBeNull() + + fireEvent.click(view.getByText('思考')) + expect(view.container.querySelector('[class*="thinkBody"]')?.textContent).toBe(text) + }) + it('expanded Think drops the inline summary and renders plain prose, no IN card', () => { const view = render( string /** Capability filter, called with a fresh projection per candidate pass. */ available(session: ClientSessionContext): boolean /** The command's UI behavior (this phase: popupSelect only). */ diff --git a/packages/client/ui-commands/src/client/locales.ts b/packages/client/ui-commands/src/client/locales.ts index 384597a9b2..0a875d467c 100644 --- a/packages/client/ui-commands/src/client/locales.ts +++ b/packages/client/ui-commands/src/client/locales.ts @@ -2,6 +2,12 @@ /** Simplified Chinese dictionary (the key-set source of truth). */ export const zh = { + 'description.compact': '压缩以上对话内容', + 'description.export': '将当前会话内容导出为 ZIP', + 'description.feedback': '发送关于当前会话的反馈', + 'description.goal': '设置或查看长期任务目标', + 'description.permission': '切换权限预设(沙箱模式与审批策略)', + 'description.plan': '进入或退出计划模式', 'search.placeholder': '搜索…', 'search.aria': '筛选选项', 'status.loading': '正在加载选项…', @@ -17,6 +23,12 @@ export type CommandKey = keyof typeof zh /** English dictionary, checked complete against the zh key set. */ export const en = { + 'description.compact': 'Compact older conversation history', + 'description.export': 'Download this Session log as a ZIP archive', + 'description.feedback': 'record feedback about this session', + 'description.goal': 'set or view the goal for a long-running task', + 'description.permission': 'Switch the permission preset (sandbox mode + approval policy)', + 'description.plan': 'Enter or leave plan mode', 'search.placeholder': 'Search…', 'search.aria': 'Filter options', 'status.loading': 'Loading options…', diff --git a/packages/client/ui-commands/src/client/service.ts b/packages/client/ui-commands/src/client/service.ts index ae7d350e46..f14f13f0be 100644 --- a/packages/client/ui-commands/src/client/service.ts +++ b/packages/client/ui-commands/src/client/service.ts @@ -26,6 +26,7 @@ import type { import type { CommandContribution, CommandDecoration, CommandUiContract } from './contract.ts' import type { CommandDescriptor } from './directory.ts' import { CommandDirectory } from './directory.ts' +import { en, type CommandKey } from './locales.ts' import { PopupSelectController } from './popup.ts' import type { TokenSegment } from './popup.ts' @@ -58,6 +59,16 @@ interface LiveState { readonly popups: Map> } +/** Locale keys for the canonical first-party Host command descriptions. */ +const HOST_DESCRIPTION_KEYS = new Map([ + ['compact', 'description.compact'], + ['export', 'description.export'], + ['feedback', 'description.feedback'], + ['goal', 'description.goal'], + ['permission', 'description.permission'], + ['plan', 'description.plan'], +]) + /** Command surface: session-keyed directory + '/' source + contribution registry + per-session popups. */ export class CommandUiRuntime extends Service implements CommandUiContract { static inject = ['inputTriggers', 'sessions', 'remote', 'remote.commands'] @@ -192,14 +203,18 @@ export class CommandUiRuntime extends Service implements CommandUiContract { const seen = new Set() for (const c of list) { seen.add(c.name) - rows.push({ name: c.name, description: c.description, ...(c.input !== undefined ? { hint: c.input.hint } : {}) }) + rows.push({ + name: c.name, + description: this.hostDescription(c), + ...(c.input !== undefined ? { hint: c.input.hint } : {}), + }) } for (const contribution of this.live.contributions.values()) { if (!contribution.available(session)) continue if (seen.has(contribution.name)) { throw new Error(`ui-commands: contribution /${contribution.name} collides with a host command`) } - rows.push({ name: contribution.name, description: contribution.description }) + rows.push({ name: contribution.name, description: contribution.description() }) } return rankByName( rows.filter(c => req.position === 'leading' || c.hint === undefined), @@ -207,6 +222,12 @@ export class CommandUiRuntime extends Service implements CommandUiContract { ) } + /** Translate exact built-in Host copy while preserving scoped or third-party descriptors verbatim. */ + private hostDescription(command: CommandDescriptor): string { + const key = HOST_DESCRIPTION_KEYS.get(command.name) + return key !== undefined && command.description === en[key] ? this.t(key) : command.description + } + /** Decision table, menu column: contribution/decorated-host → popup; host input → claim; host bare → detached execute. */ private dispatch(pick: InputTriggerPick): PickOutcome { const name = pick.candidate.name diff --git a/packages/client/ui-commands/tests/service.client.spec.ts b/packages/client/ui-commands/tests/service.client.spec.ts index 23ce7ae505..f387ebffc1 100644 --- a/packages/client/ui-commands/tests/service.client.spec.ts +++ b/packages/client/ui-commands/tests/service.client.spec.ts @@ -39,6 +39,7 @@ interface BenchOptions { /** Scripted catalog per list payload; default serves the fixed catalogs by session. */ commands?: (payload: { sessionId: SessionId }) => Promise<{ commands: CommandDescriptor[] }> execute?: (payload: { sessionId: SessionId; line: string }) => Promise + translate?: (namespace: string, key: string, params?: Record) => string addressed?: SessionId } @@ -98,7 +99,8 @@ async function bench(opts: BenchOptions = {}) { // Deterministic key-echo translator: notice assertions read `key{json}`. ctx.provide('locale', { bind: (ns: string) => (key: string, params?: Record) => - `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, + opts.translate?.(ns, key, params) + ?? `${ns}:${key}${params === undefined ? '' : JSON.stringify(params)}`, }) // Real scope tags behind a fake sessions face. const scopes = new Map } }>() @@ -164,7 +166,7 @@ const themeUi = (over: Partial = {}): CommandUiSpec => ({ const themeContribution = (over: Partial = {}): CommandContribution => ({ name: 'theme', - description: 'client popup kind', + description: () => 'client popup kind', available: () => true, ui: themeUi(), ...over, @@ -251,6 +253,35 @@ describe('candidates', () => { expect(names).toEqual(['theme']) }) + it('localizes canonical built-in and contribution descriptions on every candidate request', async () => { + let locale = 'zh' + const commands: CommandDescriptor[] = [ + { name: 'compact', description: 'Compact older conversation history' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + ] + const { command, source } = await bench({ + commands: () => Promise.resolve({ commands }), + translate: (namespace, key) => `${locale}:${namespace}:${key}`, + }) + command.register(themeContribution({ description: () => `${locale}:theme` })) + + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'zh:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'zh:theme' }, + ]) + + locale = 'en' + await expect(source.candidates(proj('s1'), req(''))).resolves.toEqual([ + { name: 'compact', description: 'en:command:description.compact' }, + { name: 'goal', description: 'scoped goal override' }, + { name: 'custom', description: 'plugin-authored copy' }, + { name: 'theme', description: 'en:theme' }, + ]) + }) + it('a contribution/host name collision fails loud', async () => { const { command, source } = await bench() command.register(themeContribution({ name: 'plan' })) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 8b75f4c92c..b28c9069b7 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 718edfca7b16747e769ead1c6916ba787ca0b100 -README.zh.md: 6d6b05e03080b025d3f6fc553c2e1a7d736b9af5 +README.md: edfcb83193d4513fba846f673d3572c35e8c0d1e +README.zh.md: f92e4c77ba3d3a2ceb96758e5817e447ba2ec1ac diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 718edfca7b..edfcb83193 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -48,7 +48,7 @@ Default sends commit optimistically: Enter clears the draft, occurrence table, a Queued submission echoes show “Sending…” beside disabled edit, remove, and steer buttons; a collapsed dock keeps the sending status in its header. A matching Host queue row replaces the echo and enables each action according to its normal text-content and running-state requirements. Prompt acknowledgement alone does not enable queue actions. A failed submission removes its echo and displays an error; the composer restores the failed draft when it is empty or still contains the previous automatic restoration, preserving subsequently typed text. -While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). +Disabled Send and Stop buttons suppress their tooltips, including a Stop button that becomes a disabled Send button when the turn ends. While a normal composer is running, its primary pointer action remains Stop when the draft is empty or input is unavailable. Actionable text or attachments switch the same seat to Send; clearing or successfully submitting the draft restores Stop. The busy-Enter setting selects the Queue or Steer delivery for ordinary Sessions and continuable children, and the running Send button delivers through the same mode plain Enter resolves to; while it is enabled (no upload pending) over a plain message draft its label names that mode (Queue message or Steer message), so the setting governs Enter and the button together while Cmd/Ctrl+Enter still uses the other mode, and idle sessions, empty drafts, and `/` command lines keep the plain Send label ([decision](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.md)). Their QueueDock rows share Edit, Remove, and Steer, and an empty draft shares the steer-all chord. One-shot children remain read-only. Plan mode and active goals do not change attachment intake. Continuable children keep separate Send and Stop actions but expose no paperclip, paste, or drop intake; if their parent is offline, Send and the composer gestures lock while QueueDock controls for the live inbox remain available ([decisions](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md), [inbox controls](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.md)). ## Temporary composer entries diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 6d6b05e030..f92e4c77ba 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -48,7 +48,7 @@ Session 首次绑定或缓存的 Session 成为 current 时,shell 会在渲染 排队提交的本地回显在禁用的编辑、删除、插话按钮旁显示“发送中…”;折叠后的队列在标题栏保留发送状态。匹配的 Host 队列行替换回显后,各操作按原有的纯文本内容和运行状态要求启用。仅收到 prompt 确认不会启用队列操作。提交失败会移除回显并显示错误;输入框为空或仍保留上一次自动恢复的内容时,composer 恢复失败草稿,保留用户随后输入的文字。 -普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 +Send 和 Stop 按钮禁用时不显示提示气泡,轮次结束后由 Stop 切换成禁用 Send 的按钮也遵循此规则。普通 composer 运行时,如果草稿为空或输入不可用,主指针操作保持为 Stop。可提交的文字或附件会把同一位置切换为 Send;清空或成功提交草稿后恢复 Stop。繁忙态 Enter 设置为普通 Session 与可继续 child 选择 Queue 或 Steer 投递,运行中的 Send 按钮按 plain Enter 解析出的同一模式投递;当它在普通消息草稿上可用(没有待上传文件)时,其标签以该模式命名(排队发送或插话发送),因此该设置同时约束 Enter 与按钮,而 Cmd/Ctrl+Enter 仍使用另一模式;空闲会话、空草稿与 `/` 命令行保留普通的 Send 标签([决策](../../../.agents/notes/implemented/bug-fix/2026-09-04-busy-send-button-follows-enter-setting.zh.md))。它们的 QueueDock 行共享 Edit、Remove 与 Steer,空草稿也共享 steer-all 组合键。One-shot child 继续只读。Plan Mode 与 active goal 不改变附件入口。可继续 child 保留独立的 Send 与 Stop 操作,但不提供回形针、粘贴或拖放入口;parent 离线时,Send 与 composer 手势锁定,但在线 inbox 的 QueueDock 控制仍可使用([决策](../../../.agents/notes/archived/bug-fix/2026-08-20-running-draft-primary-send.md)、[inbox 控制](../../../.agents/notes/implemented/feature/2026-08-27-continuable-subagent-human-inbox-control.zh.md))。 ## 临时 composer entry diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index aa37b85ecf..3025bbe691 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -344,6 +344,8 @@ export const InputBar = memo(function InputBar({ // state keeps plain Send. A continuable child keeps Send primary and // exposes Stop independently. const primaryStops = running && subagent === null && (empty || blocked !== undefined) + // Disabled native buttons may omit mouseleave; their tooltip must close from state. + const primaryDisabled = primaryStops ? stop === undefined : empty || disabled || machineBusy || uploadsPending const interruptible = running && continuable const primarySubmitMode = resolveSubmitMode(busyEnter, running, 'enter', steeringAvailable) const plainMessageDraft = !empty && input?.phase === 'plain' && !draft.trimStart().startsWith('/') @@ -526,7 +528,7 @@ export const InputBar = memo(function InputBar({ {sessionId === undefined ? null : renderSlot('conversation.input.model', { locked: modelSeatLocked })} {interruptible && ( - +