diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml index d8ddbe592a..a64c2c83c2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.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-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 55421d1ad6df192d08c431af3633675036a4a857 -2026-07-19-gui-web-client-architecture.zh.md: 6fb3f9a512389710f6708b7f36f42e90eef11b28 +2026-07-19-gui-web-client-architecture.md: 4d62d7ddbeddd2d5c02e42194b035ee08b5cf041 +2026-07-19-gui-web-client-architecture.zh.md: aeb6c89e677d7b65daef03d5d31372f484ae3117 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md index 55421d1ad6..4d62d7ddbe 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md @@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ``` - **Session** (session.ts): lazily built, resident — once created it keeps eating frames in the background, so switching away and back renders instantly. Operations: `prompt`/`cancel` (RPC passthrough; failures land in the snapshot's `promptError`), `open` (pull the tail history page, idempotent), `loadOlder` (upward paging, reentry-guarded), `resync` (reconnect = clear the window and rerun open). Subscription: `subscribe`/`getSnapshot` (always the cached reference) — `implements ObservableSnapshot`, with `useSelector = bindSnapshotSelector(this)` attached at construction, so a Session is directly a uSES source. Frame dispatch is one switch: `session/event` frames dedup by seq (the only dedup key), buffer while open is in flight, otherwise append + incremental projection; open/stitch merges the live buffer by seq and backfills once if `subscribed.lastSeq` outruns the window tail. -- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, queue, running, removal, open state, paging, and prompt errors remain Session facts. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React still subscribes to the Session as the sole observable source, while the framework-provided `useSession(selector)` isolates Node and Location aggregate updates. +- **ConversationSnapshot** (conversation.ts): the top-level immutable snapshot contract. `chat` contains structural `order`, an identity-stable keyed Node reader, Turn/Step indexes, and the timeline; `nodes`, `partial`, `runningCalls`, `turnTimings`, and `turnEnds` are the compatibility slice for unmigrated Trajectory consumers. Pending interactions, running, removal, open state, paging, and prompt errors remain Session facts; pending Inbox values live in the generic Session projection store. **Reference discipline** (the premise of memo and uSES): unchanged substructures and Node values keep their references; one business update replaces only the corresponding key's value unless its order or Location changes. React reads Session lifecycle through `useSession(selector)` and domain projections through `useProjection(key, selector)`, so each hook isolates unrelated updates. - **SessionManager** (manager.ts): instance cluster + frame entry + the session list. sessionId-bearing frames go only to existing instances (a mux broadcast must not instantiate every session); approval/question `requested` frames are the exception — they never land in history, so they buffer in `pendingBuffers` and replay on instantiation. - **Notifier** (notifier.ts): two channels chosen by change source. `markDirty()` (default; frame-driven changes always) batches per microtask — N changes, one notification, one re-render; the flush rebuilds the snapshot cache before notifying. `notifyNow()` (only direct echoes of user gestures) rebuilds and notifies in the same tick — controlled inputs roll the DOM back and jump the caret if their echo defers to a microtask. Frame-driven code using notifyNow collapses batching back to per-frame renders; banned. - **ConversationNodeAssembler** (`runtime/src/client/conversation/`): the Session-owned incremental engine runs independently registered Definitions over raw events. `match(event)` selects `(kind, id)` without Context scans; start/update build Definition state; engine-computed Locations carry Turn/Step closure; backward Context reads record dependencies repaired by later prepends; `buildViewNode(target)` materializes only dirty Contexts. The Chat builder preserves structural order and per-key value identity, `useSession` selectors isolate consumption, and Assistant token publication coalesces to one animation frame. The [Conversation Node decision](2026-08-09-client-conversation-node-assembly.md) owns assembly, while [Tool presentation ownership](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md) owns recursive Tool rendering. diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index 6fb3f9a512..aeb6c89e67 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -69,7 +69,7 @@ Notifier 微任务合批 ──► ConversationSnapshot 缓存 ──uSES── ``` - **Session**(session.ts):懒建、常驻——建成后在后台持续吃帧,切走切回秒显。操作面:`prompt`/`cancel`(RPC 透传;失败落进快照的 `promptError`)、`open`(拉尾页 history,幂等)、`loadOlder`(向上翻页,防重入)、`resync`(重连 = 清窗口重跑 open)。订阅面:`subscribe`/`getSnapshot`(恒返缓存引用)——`implements ObservableSnapshot`,构造时挂 `useSelector = bindSnapshotSelector(this)`,Session 本身就是 uSES 源。帧分发是一个 switch:`session/event` 帧按 seq 去重(唯一去重键),open 在途时缓冲,否则追加 + 增量投影;open/缝合按 seq 合并 live 缓冲并去重,`subscribed.lastSeq` 超出窗口尾则回补一次。 -- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、queue、running、removed、open state、paging 和 prompt error 仍是 Session 信息。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 仍只订阅 Session 这一处 observable source,并由框架提供的 `useSession(selector)` 隔离 Node 与 Location 聚合更新。 +- **ConversationSnapshot**(conversation.ts):顶层不可变快照约定。`chat` 包含结构化 `order`、identity 稳定的 keyed Node reader、Turn/Step index 和 timeline;`nodes`、`partial`、`runningCalls`、`turnTimings`、`turnEnds` 是未迁移 Trajectory 消费方使用的兼容 slice。pending interaction、running、removed、open state、paging 和 prompt error 仍是 Session 信息;待处理 Inbox 值则位于通用 Session projection store。**引用纪律**(memo 与 uSES 的前提):未变化的子结构和 Node value 保持引用;单个业务更新只替换对应 key 的 value,除非它的顺序或 Location 发生变化。React 通过 `useSession(selector)` 读取 Session lifecycle,通过 `useProjection(key, selector)` 读取领域投影,使每个 hook 都隔离无关更新。 - **SessionManager**(manager.ts):实例簇 + 帧总入口 + 会话列表。带 sessionId 的帧只投已存在实例(mux 广播不得把每个会话都实例化);例外是审批/问答 `requested` 帧——它们不落 history、open 无法回补,故缓冲进 `pendingBuffers`,实例化时回放。 - **Notifier**(notifier.ts):两条通知通道,按变更来源取用。`markDirty()`(默认;帧驱动一律用它)按微任务合批——N 次变更、一次通知、一次重渲染;flush 先重建快照缓存再通知。`notifyNow()`(仅用户手势的直接回响)同 tick 重建并通知——受控输入的回响若延到微任务,DOM 会回滚、光标跳尾。帧驱动代码用 notifyNow 会让合批塌回逐帧渲染;禁。 - **ConversationNodeAssembler**(`runtime/src/client/conversation/`):Session 拥有的增量引擎在原始事件上运行各自独立注册的 Definition。`match(event)` 无须扫描 Context 即可选出 `(kind, id)`;start/update 构造 Definition state;引擎计算的 Location 携带 Turn/Step 关闭信息;向前查询 Context 时记录依赖,并由后续 prepend 修复;`buildViewNode(target)` 只物化 dirty Context。Chat builder 保留结构顺序和 per-key value identity,`useSession` selector 负责消费隔离,Assistant token 发布则合并到每个 animation frame 一次。[Conversation Node 决策](2026-08-09-client-conversation-node-assembly.zh.md)拥有组装边界,[Tool 展示所有权](../../archived/architecture/2026-08-08-client-tool-presentation-ownership.md)拥有 Tool 递归渲染。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index f1780bcbf7..f9a0dfdce3 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: 4cdca4c1cb5b512c1696a397bbb6e4d070a6a487 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 0258609fd8df90dc2133fcb8bf6e7e50efaab941 +2026-07-25-web-client-session-scope-and-provide-channel.md: 620102f9f5e7fd76f38bf0031b856b26c2a7840d +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 3f00658c672908ba92627416ba5d5db381c0d9cd diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index 4cdca4c1cb..620102f9f5 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -99,7 +99,7 @@ Slot scope is the closed set `root | session-maybe | session`: - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). - Third-party components take zero value dependencies; types are a one-line type-only import (declaration merging into `SessionStandardProps` / `SessionMaybeStandardProps`). -### The read-only queue mirror +### Input delivery - Queue semantics: running does not lock input; ordinary messages queue through `session.prompt {mode:'queue'}`, and commands never queue. diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 0258609fd8..3f00658c67 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -99,7 +99,7 @@ slot scope 是闭集 `root | session-maybe | session`: - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 - 第三方组件值零依赖,类型一行 type-only import(declaration merging 进 `SessionStandardProps` / `SessionMaybeStandardProps`)。 -### 队列只读镜像 +### 输入投递 - 队列语义:running 不锁输入;普通消息经 `session.prompt {mode:'queue'}` 排队,命令永不排队。 diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml index 1b529c379b..049fbfff18 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.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-31-claimed-pre-step-inbox-lifecycle.md -2026-07-31-claimed-pre-step-inbox-lifecycle.md: 737e3835263a3215a0fd2e52dad4ee05402bd888 -2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: ecb731df663e0d48b374a3118d7db7f6a34bfc18 +2026-07-31-claimed-pre-step-inbox-lifecycle.md: 3ebb73279e49c2aaffa05bea647fa3f93ba6a36f +2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md: 809470e5b52b29cdcbe7b4d81bb584b476f5cfce diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md index 737e383526..3ebb73279e 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md @@ -18,13 +18,13 @@ Before every proposed step, the loop's package-internal `ReactLoopInbox` atomica The durable inbox remains two `UserMessage[]` lists addressed by `MessageId`. `append`, `prepend`, and `splice` take a target, while `replace(messageId, newMessage)` and `remove(messageId)` locate the pending message across both lists before committing a normalized splice. Replacement may change identity and emits the old message as discarded followed by the new message as inserted. Every insertion emits `agent/inbox/inserted { message }`; an ordinary removal records `outcome: 'canceled'` and emits `agent/inbox/discarded { message }`. Claiming records pure deletions without an outcome and emits claimed events from `ReactLoopInbox`. These live events add no placement, outcome, or batch fields. -`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context` and registers the standard definition on the agent scope before its first read. `AgentLoop` requires the registry service at activation, and the registry reference-counts the definition across live agent scopes. +`Agent.inbox` exposes only the structural `Inbox` interface for reading and mutating pending work; loop-only `hasPending` and claim operations are absent from that public face. dsh-agent-loop constructs one `ReactLoopInbox` and uses it for both structural commands and driver operations. The concrete constructor receives `SessionProjectionRegistry` directly instead of the wider Cordis `Context`. `AgentLoop` owns the standard projection registration for its service lifetime, keeping cold Inbox reads available without any live Agent; `ReactLoopInbox` only reads and mutates that shared state. -The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. Each `ReactLoopInbox` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream from its agent scope; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the projection change feed: the Session controller publishes the projection frame, then derives the queue replacement from the same post-fold inbox value. +The two event surfaces have separate consumers. Observers following one message use `agent/inbox/inserted`, `claimed`, and `discarded`. `AgentLoop` contributes the standard `inbox` projection over the durable `agent/inbox/spliced` stream; UI edits and removals route through an Inbox mutation method so the same projection records every change. When that projection reconstructs durable history, it rejects unsafe or out-of-range coordinates and duplicate `MessageId` values across both lists, and reports the offending event seq. Whole-queue control consumers use the generic projection change feed and read the complete Inbox value directly. Plugins that need current-step atomic rewriting return messages from `agent/pre-step`. Plugins that only need later context may mutate `agent.inbox` directly. Workspace context uses both paths: asynchronous filesystem projections stage one replaceable `next-step` item, while the next entering pre-step folds that item or a newly composed baseline into its final batch and removes the pending copy. Rejection keeps the item queued. -The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `ReactLoopInbox` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. +The archived [addressable queue occurrence decision](../../archived/feature/2026-07-29-addressable-queue-operations.md) describes the superseded occurrence-wrapper design. `MessageId` owns addressability, while `AgentLoop` contributes `inbox` as the standard session projection over durable splices. The generic projection carrier serves that fold for live updates, history-tail reconnect baselines, and cold process-restart recovery without a live Agent mirror. ## Alternatives considered @@ -36,7 +36,7 @@ The archived [addressable queue occurrence decision](../../archived/feature/2026 ## Verification -Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and agent-scope projection removal after the last owner unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads. +Agent-loop coverage pins turn-start-before-claim-before-pre-step ordering, exact live event payloads, balanced no-step rejection, final-batch rewriting, input inserted after a claim, listener failure, cancellation, and Inbox availability across Agent unloads and projection removal when AgentLoop unloads. Inbox and consumer tests pin pure claim deletions, canceled ordinary removals, agent-instructions staging, replacement, and same-step entry, plan/goal/hook behavior, UI cleanup, compaction, checkpointing, resumed durable projection, rejection of invalid persisted coordinates or cross-list identities, and post-fold queue replacement when the controller registers before the projection registry. Consumer-domain tests use a process-local Inbox stub only when durability is outside the test subject; claiming, durable projection, recovery, validation, and live-notification tests create Agents through the production AgentLoop test harness, so test support never reimplements the projection. Generated event and type catalogs expose only the new waterfall and payloads. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md index ecb731df66..809470e5b5 100644 --- a/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md @@ -18,13 +18,13 @@ Status: implemented 持久 inbox 仍是两份通过 `MessageId` 寻址的 `UserMessage[]` 列表。`append`、`prepend` 与 `splice` 接受 target;`replace(messageId, newMessage)` 与 `remove(messageId)` 则在提交规范化 splice 前,通过 `MessageId` 跨两份列表定位待处理消息。替换可以改变标识,并先将旧消息作为 discarded 发布,再将新消息作为 inserted 发布。每次插入发出 `agent/inbox/inserted { message }`;普通删除记录 `outcome: 'canceled'` 并发出 `agent/inbox/discarded { message }`。领取记录不带 outcome 的纯删除,并由 `ReactLoopInbox` 发出 claimed 事件。这些实时事件不增加 placement、outcome 或批次字段。 -`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`,并在首次读取前从 agent 作用域注册标准定义。`AgentLoop` 激活时要求该注册表服务存在,注册表则对多个 live agent 作用域贡献的定义进行引用计数。 +`Agent.inbox` 只暴露用于读取和变更待处理工作的结构化 `Inbox` 接口;仅供循环使用的 `hasPending` 与领取操作不在该公开接口上。dsh-agent-loop 只构造一个 `ReactLoopInbox`,同时用于结构化命令与驱动器操作。具体构造函数直接接收 `SessionProjectionRegistry`,而不是更宽泛的 Cordis `Context`。`AgentLoop` 在服务生命周期内持有标准投影注册,让没有 live Agent 时的冷 Inbox 读取仍然可用;`ReactLoopInbox` 只读取和变更该共享状态。 -两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。每个 `ReactLoopInbox` 都从其 agent 作用域在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用投影变更流:Session controller 先发布 projection frame,再从同一份折叠后的 inbox 值派生 queue replacement。 +两类事件接口服务不同消费方。跟踪单条消息的观察方使用 `agent/inbox/inserted`、`claimed` 与 `discarded`。`AgentLoop` 在持久 `agent/inbox/spliced` 流上贡献标准 `inbox` 投影;UI 编辑与移除通过 Inbox 变更方法处理,从而让同一投影记录所有变化。该投影重建持久历史时,会拒绝不安全或越界的坐标,以及跨两份列表重复的 `MessageId`,并报告出错事件的 seq。整体队列的 control 消费方使用通用投影变更流,直接读取完整的 Inbox 值。 必须对当前步骤进行原子改写的插件从 `agent/pre-step` 返回消息。只需要稍后上下文的插件可以直接修改 `agent.inbox`。Workspace context 同时使用两条路径:异步文件系统投影会暂存一条可替换的 `next-step` 消息,而下一次进入步骤的 pre-step 会把该消息或新组合的基线折入最终批次,并移除仍待处理的副本。reject 会让该条目继续排队。 -已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `ReactLoopInbox` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 +已归档的[可寻址队列项决策](../../archived/feature/2026-07-29-addressable-queue-operations.md)描述了已被取代的单次出现包装层设计。`MessageId` 负责寻址,而 `AgentLoop` 把 `inbox` 作为持久 splice 上的标准会话投影贡献给投影注册表。通用投影传输层会将该折叠结果用于实时更新、历史尾页的重连基线和冷进程重启恢复,无需 live Agent 镜像。 ## 曾考虑的替代方案 @@ -36,7 +36,7 @@ Status: implemented ## 验证 -agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及最后一个所有者卸载后移除 agent 作用域投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 +agent loop(智能体循环)覆盖固定先 `turn/start`、再领取、后 pre-step 的顺序、实时事件的确切载荷、边界平衡的无步骤 reject、最终批次改写、领取后插入的输入、监听器失败、取消,以及Agent 卸载后 Inbox 仍可读取,以及 AgentLoop 卸载时移除投影。Inbox 和消费方测试固定纯领取删除、普通删除的 canceled 结果、agent-instructions 的暂存、替换与同一步骤进入、plan/goal/钩子行为、UI 清理、压缩(compaction)、检查点、恢复后的持久投影、对非法持久坐标或跨列表重复标识的拒绝,以及 controller 早于投影注册表注册时仍使用折叠后队列值。只有当持久性不属于测试对象时,消费方领域测试才使用进程内 Inbox 桩;领取、持久投影、恢复、校验与实时通知测试通过生产 AgentLoop 测试 harness 创建 Agent,因此测试支持代码不会重新实现该投影。生成的事件与类型目录只公开新的 waterfall 与载荷。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml index 169d9510f7..3b16c5a3d9 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.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-08-18-session-history-and-event-transport.md -2026-08-18-session-history-and-event-transport.md: 8781ea265798ff200a6e0a58542b7d03693d8bd3 -2026-08-18-session-history-and-event-transport.zh.md: 1603f5aed8adb7a42fecab92511176d2147be5c3 +2026-08-18-session-history-and-event-transport.md: db5c403562101bca76c0d39a037a199b2ca164f3 +2026-08-18-session-history-and-event-transport.zh.md: 41ff3ea2dd538edb457e0db3108a900bab220e47 diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md index 8781ea2657..db5c403562 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.md @@ -8,7 +8,7 @@ English | [中文](2026-08-18-session-history-and-event-transport.zh.md) The browser consumes three kinds of data with different lifecycles: persistable, paginated Session logs; process-local state that needs an opening baseline to converge after reconnect; and immediate notifications that need no replay. -These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; queue, jobs, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. +These kinds of data cannot share one recovery rule. Session logs have stable sequence numbers and persistence, so a cursor can fill gaps; jobs, projection values, and Workspace lists need a complete snapshot to replace an old mirror; ordinary notifications only promise delivery within the current Connection generation. Observing Session history, lists, and projections must allow cold reads. If transport performs a general Typert lookup whenever an argument contains a Session or Agent, opening a page, switching tabs, or reconnecting the network implicitly resumes an Agent, so observation gains execution side effects. @@ -158,7 +158,8 @@ Each method explicitly selects a cold inspection, live-only lookup, or resume-ca | `session.follow(address)` | one live or prepared observation carrying the opening page and projections | Publishes the snapshot first, then promotes an ordinary cold Session once in the background | | `session.control()` | current attached Agents, pending registry, and process-local registries | Baseline and reconnect do not resume an Agent | | `session.attachment`, fork source read | authorized durable Session data | A read does not resume an Agent | -| `session.updateQueue`, `cancel` | only the current live Agent | Does not resume vanished state | +| `session.updateQueue` | live Agent or ordinary persisted Session | Resumes an ordinary cold Session before mutating its Inbox | +| `session.cancel` | only the current live Agent | Does not resume vanished state | | `models`, `selectModel`, `rename`, `prompt` | command resolves the target Session | Resumes only when the method explicitly permits it | | `create` and fork target | new Session/Agent | The user command supplies creation authority | @@ -204,9 +205,9 @@ A terminal failure from the initial page, repair page, or follow enters the curr `session.control()` is a Host-wide snapshot stream. One browser can observe transient state for all current live Sessions without opening a journal for every transcript. -Each generation emits a complete baseline first, followed by queue, jobs, and projection deltas. The baseline reads attached Agents and process-local registries without resuming cold Agents. +Each generation emits a complete baseline first, followed by jobs and projection deltas. The baseline reads process-local registries and folded projection values without resuming cold Agents. -Queue and jobs use complete replacement values and apply last-wins. Agent attach, detach, Session disposal, and owner disposal can all clear a stale mirror through an empty value or a new baseline. +Jobs use complete replacement values and apply last-wins. Projection updates carry monotonically increasing revisions, while a new baseline replaces the complete projection map. Session and owner disposal clear stale mirrors. The original `approval/request` and `user-questions/request` events are forwardable waterfalls. If an Agent-scoped Client listener claims a request, it returns directly. If all delivered Clients call `next()`, the original Cordis waterfall continues to later Host listeners. Session control neither stores nor replays these requests. @@ -313,7 +314,7 @@ API Proxy carries only independent business APIs it owns. Session, Workspace, Re **Split Session transport and Session commands into two public packages.** Both depend on Session address, Agent activation policy, subagent ownership, error mapping, and Client mount ordering. One public Controller preserves unified ownership while internal classes can evolve independently. -**Move queue, jobs, projection, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. +**Move jobs, projections, Workspace, and logs to ordinary `$on`.** Ordinary events have no reconnect baseline, cursor, or gap repair, so one missed delivery leaves permanently stale state. Only notifications that need no recovery, can be repaired by an independent query, or carry their own lifetime as a waterfall fit `$on`. **Make every domain Controller inherit a page/follow/retry base class.** Session journals and Workspace snapshots have different opening, recovery, and ordering rules. Gateway's three compositional stream objects reuse transport lifecycle while domain adapters declare only their own frame semantics. @@ -345,7 +346,7 @@ Connection tests pin missing, duplicate, and withdrawn generation sources, readi Session Host tests pin cold page/follow without increasing attached Agents, contiguous events reaching a cold follow after an explicit prompt, direct-subagent ownership, message-aligned pagination, and terminal-error projection. -Session control tests pin baseline-first delivery, no cold-Session resume, attach/detach cleanup, queue and jobs replacement, and the projection watermark. +Session control tests pin baseline-first delivery, no cold-Session resume, jobs replacement, and the projection watermark. Session Client tests pin one journal owner per Session, no writeback from stale open epochs, independent cancellation of control and journal, and retaining the published window during carrier retry. diff --git a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md index 1603f5aed8..41ff3ea2dd 100644 --- a/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-18-session-history-and-event-transport.zh.md @@ -8,7 +8,7 @@ Status: implemented 浏览器同时消费三类生命周期不同的数据:可持久化并分页的 Session 日志、需要 opening baseline 才能在重连后收敛的进程内状态,以及无需重放的即时通知。 -这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;queue、jobs、Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 +这三类数据不能共用一种恢复规则。Session 日志有稳定 seq 和 persistence,可以按 cursor 补齐缺口;jobs、projection 值和 Workspace 列表等状态需要以完整 snapshot 替换旧镜像;普通通知只保证当前 Connection generation 内投递。 观察 Session 历史、列表和投影必须允许冷读取。若 transport 因参数中出现 Session 或 Agent 就触发通用 Typert lookup,打开页面、切换标签或网络重连都会隐式恢复 Agent,观察操作因此产生执行副作用。 @@ -158,7 +158,8 @@ Session Remote 方法传递 `SessionId` 或 `SessionAddress`,不靠参数类 | `session.follow(address)` | 一份携带 opening page 与 projection 的 live 或 prepared observation | 先发布 snapshot,再在后台把普通冷 Session 提升一次 | | `session.control()` | 当前 attached Agent、pending registry 与进程内 registry | baseline 与重连不恢复 Agent | | `session.attachment`、fork 源读取 | 已授权的持久 Session 数据 | 读取不恢复 Agent | -| `session.updateQueue`、`cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | +| `session.updateQueue` | live Agent 或普通持久 Session | 修改 Inbox 前恢复普通冷 Session | +| `session.cancel` | 仅命中当前 live Agent | 不为已消失状态恢复 Agent | | `models`、`selectModel`、`rename`、`prompt` | 命令解析目标 Session | 仅按方法约定显式恢复 | | `create` 与 fork 目标 | 新 Session/Agent | 用户命令提供创建授权 | @@ -204,9 +205,9 @@ initial page、repair page 或 follow 的 terminal failure 进入当前 Session `session.control()` 是 Host 范围的 snapshot stream,一个浏览器可观察所有当前 live Session 的瞬态状态,而不必为每个 transcript 打开 journal。 -每个 generation 先发完整 baseline,再发 queue、jobs 与 projection 增量帧。baseline 读取 attached Agent 和进程内 registry,不恢复冷 Agent。 +每个 generation 先发完整 baseline,再发 jobs 与 projection 增量帧。baseline 读取进程内 registry 和已折叠的 projection 值,不恢复冷 Agent。 -queue 与 jobs 使用完整 replacement 值并按 last-wins 应用。Agent attach、detach、Session disposal 与 owner disposal 都能用空值或新 baseline 清除陈旧镜像。 +jobs 使用完整 replacement 值并按 last-wins 应用。Projection update 携带单调递增 revision,新 baseline 则替换完整 projection map。Session 与 owner disposal 会清理陈旧镜像。 原始 `approval/request` 与 `user-questions/request` 是可转发 waterfall。若某个 Agent-scoped Client listener claim,请求直接返回;若所有已投递 Client 都调用 `next()`,原 Cordis waterfall 继续到后续 Host listener。Session control 不保存或重放这些请求。 @@ -313,7 +314,7 @@ API Proxy 只承接自身拥有的独立业务 API,不是 Session、Workspace **把 Session transport 与 Session commands 拆成两个公开包。** 两者共同依赖 Session address、Agent 激活策略、subagent ownership、错误映射和 Client 挂载顺序;一个公开 Controller 保持统一所有权,内部 class 仍可独立演化。 -**把 queue、jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 +**把 jobs、projection、Workspace 与日志都改成普通 `$on`。** 普通事件没有 reconnect baseline、cursor 或 gap repair,漏掉一次推送就会留下永久陈旧状态;只有无需恢复、可由独立查询修复,或以 waterfall 本身持有请求生命周期的通知适合 `$on`。 **让每个领域 Controller 继承一个 page/follow/retry 基类。** Session journal 与 Workspace snapshot 的 opening、恢复和排序规则不同;Gateway 的三个组合式 stream 对象复用 transport 生命周期,同时让领域 adapter 只声明自己的 frame 语义。 @@ -345,7 +346,7 @@ Connection 测试固定 generation source 缺失、重复注册、撤回、ready Session Host 测试固定 cold page/follow 不增加 attached Agent、显式 prompt 后 cold follow 收到连续事件、direct subagent ownership、message-aligned pagination 和终止错误投影。 -Session control 测试固定 baseline-first、冷 Session 不恢复、attach/detach 清理、queue 与 jobs replacement,以及 projection watermark。 +Session control 测试固定 baseline-first、冷 Session 不恢复、jobs replacement 与 projection watermark。 Session Client 测试固定每 Session 单一 journal owner、旧 open epoch 不写回、control 与 journal 独立取消,以及 carrier retry 期间保留已发布窗口。 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml index e846f8f186..0094a4b396 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.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-08-22-single-dsh-application-launcher.md -2026-08-22-single-dsh-application-launcher.md: 630040c75c4c20c57b5e26288663265174331ca5 -2026-08-22-single-dsh-application-launcher.zh.md: 9bd51e6c4e8b7ab60cbabc384431bc4e22a6b522 +2026-08-22-single-dsh-application-launcher.md: 46a627eed652a0a0edaf7b900510b5d369efcdf0 +2026-08-22-single-dsh-application-launcher.zh.md: b7fa0f2b35c2fbf6c3a28f463b73170c84547a40 diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md index 630040c75c..46a627eed6 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.md @@ -14,7 +14,7 @@ The Python SDK distributes a native executable through four platform wheels. Its ### Launch scope -Every supported Node application starts through the `dsh` CLI and one named profile. The shipped application commands are `dsh web`, `dsh --profile headless`, `dsh --profile sdk`, `dsh --profile sdk-minimal`, and `dsh --profile acp`; `dsh web` is the deliberate convenience alias for `--profile web`, not another application entry. +Every supported Node application starts through the `dsh` CLI and one named profile. The shipped profiles are `web`, `headless`, `sdk`, `sdk-minimal`, and `acp`, selected with `dsh --profile ` or `dsh `. `plugin` names the management command; a profile with that name requires `--profile plugin`. Vendor CLIs, build-only and test-only executables, direct in-process plugin mounting, and the private browser WebWorker preview are outside the application-launch inventory. A package app bin or root demo that launches a package entry is not an accepted extension point. @@ -46,7 +46,7 @@ Direct SDK use follows normal Harness-home resolution: explicit `dshHome`, inher ### Python runtime -The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar and the separately packaged `web` application. +The Python runtime wheel stages [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) as the `dsh-python-runtime-closure` entry. Its ordinary branch calls the public CLI export; a provider-private selector dispatches to the internal subprocess runner before CLI parsing and is not an application entry point. The [native-containment decision](2026-08-28-subprocess-native-containment.md) owns that private dispatch. The Python client selects `dsh --profile sdk` by default, ordered patch files, and an explicit Harness home; the runnable example under `python/sdk/examples` selects `sdk-minimal`. The installed `dsh` console command exposes the same profile grammar, including the `web` profile. The executable family is `deepseek-harness-sdk-runtime--`. The SDK wire, wheel and import distribution names, sidecar names, and wire identity `deepseek-harness-sdk-runtime` remain stable. The SDK package family is `@deepseek-ai/dsh-sdk-client`, `@deepseek-ai/dsh-sdk-protocol`, and `@deepseek-ai/dsh-sdk-jsonrpc-server`; `@deepseek-ai/dsh-acp` remains the ACP protocol plugin. There is no Python-specific Node application, checked-in complete config, compatibility package, forwarding executable, fallback parser, or SDK/ACP launcher alias. [docs/architecture.md](../../../../docs/architecture.md) owns this launch, and the [`python/sdk-runtime` README](../../../../python/sdk-runtime/README.md) owns the Windows carrier. @@ -56,6 +56,8 @@ The executable family is `deepseek-harness-sdk-runtime--`. The S ## Existing decisions and supersession +[Profile command shorthand](../feature/2026-09-15-profile-command-shorthand.md) supersedes this note's Web-only shorthand mechanism; this note retains authority over application composition and lifecycle ownership. + This decision supersedes the application-launch and package-name facts in [profile plugin bundles](2026-08-05-profile-plugin-bundles.md), [TypeScript SDK client and subagent backend](../../archived/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md), [remove the SDK project toolchain](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md), and [single-file Python SDK runtime distribution](2026-07-10-single-file-executable-sdk-runtime-distribution.md). Those notes retain independent authority for profile layering, client/wire semantics, deleted project tooling, and native packaging. The [ACP automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md) remains authoritative for ACP wire and interaction scope. The [adding-a-package cookbook](../../../../docs/cookbook/adding-a-package.md) owns role-based package names. The [standalone sdk-minimal profile](../../archived/architecture/2026-08-24-standalone-sdk-minimal-profile.md) partially supersedes this note's base-first rule and complete-tree alternative while retaining this note's launcher ownership. No active note is fully superseded or eligible for archival. diff --git a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md index 9bd51e6c4e..b7fa0f2b35 100644 --- a/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-22-single-dsh-application-launcher.zh.md @@ -14,7 +14,7 @@ Python SDK 通过四个平台 wheel 包分发原生可执行文件。其打包 ### 启动范围 -所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附应用命令是 `dsh web`、`dsh --profile headless`、`dsh --profile sdk`、`dsh --profile sdk-minimal` 与 `dsh --profile acp`;`dsh web` 是刻意为 `--profile web` 保留的便捷别名,不是另一个应用入口。 +所有受支持的 Node 应用都通过 `dsh` CLI 与一个具名 profile 启动。随附 profile 为 `web`、`headless`、`sdk`、`sdk-minimal` 和 `acp`,可通过 `dsh --profile ` 或 `dsh ` 选择。`plugin` 表示管理命令;同名 profile 必须用 `--profile plugin` 选择。 Vendor CLI、仅用于构建和测试的可执行文件、进程内直接挂载插件以及私有浏览器 WebWorker 预览都不属于应用启动清单。包应用 bin 或直接启动包入口的根 demo 都不是可接受的扩展点。 @@ -46,7 +46,7 @@ SDK 用户通过 profile 自定义插件。`dsh plugin --profile ...` 管 ### Python 运行时 -Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同 profile 语法与单独打包的 `web` 应用。 +Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../../../python/sdk-runtime/runtime-bootstrap.mjs) 暂存为 `dsh-python-runtime-closure` 入口。其普通分支调用公开 CLI export;提供方私有选择会在 CLI 解析前分派到内部子进程 runner,而不是应用入口。[原生 containment 决策](2026-08-28-subprocess-native-containment.zh.md)负责该私有分派。Python 客户端默认选择 `dsh --profile sdk`、有序 patch 文件与显式 Harness home;`python/sdk/examples` 下的可运行示例选择 `sdk-minimal`。安装的 `dsh` 控制台命令暴露相同的 profile 语法,包括 `web` profile。 可执行文件族是 `deepseek-harness-sdk-runtime--`。SDK 协议格式、wheel 与 import 分发名称、伴随文件名称,以及协议 identity `deepseek-harness-sdk-runtime` 保持稳定。SDK 包族是 `@deepseek-ai/dsh-sdk-client`、`@deepseek-ai/dsh-sdk-protocol` 与 `@deepseek-ai/dsh-sdk-jsonrpc-server`;`@deepseek-ai/dsh-acp` 继续作为 ACP 协议插件。仓库不保留 Python 专用 Node 应用、检入的完整配置、兼容包、转发可执行文件、后备解析器或 SDK/ACP 启动别名。[docs/architecture.md](../../../../docs/architecture.zh.md)负责该启动方式,[`python/sdk-runtime` README](../../../../python/sdk-runtime/README.zh.md)负责 Windows 载体。 @@ -56,6 +56,8 @@ Python 运行时 wheel 将 [`python/sdk-runtime/runtime-bootstrap.mjs`](../../.. ## 既有决策与取代关系 +[Profile 命令简写](../feature/2026-09-15-profile-command-shorthand.zh.md)取代本 Note 中仅为 Web 提供简写的机制;本 Note 继续负责应用组合与生命周期的所有权。 + 本决策取代 [profile 插件组合包](2026-08-05-profile-plugin-bundles.zh.md)、[TypeScript SDK 客户端与 SDK subagent 后端](../../archived/feature/2026-07-27-typescript-sdk-and-sdk-subagent-backend.md)、[移除 SDK 项目工具链](../../archived/simplification/2026-08-11-remove-sdk-project-toolchain.md)和[单文件 Python SDK 运行时分发](2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md)中的应用启动与包名事实。这些 Note 对 profile 分层、客户端/协议语义、已删除的项目工具链与原生打包仍分别具有独立权威。 [ACP 仅自动化协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)继续负责 ACP 协议格式与交互范围。[添加包实操手册](../../../../docs/cookbook/adding-a-package.zh.md)负责基于角色的包名。[独立 sdk-minimal profile](../../archived/architecture/2026-08-24-standalone-sdk-minimal-profile.md)部分取代本 Note 的 base 优先规则与完整配置树替代方案,同时保留本 Note 对 launcher 所有权的决策。没有任何活跃 Note 被完全取代,也没有 Note 符合归档条件。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.i18n.yaml new file mode 100644 index 0000000000..c18976d463 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.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-08-17-durable-web-queue-recovery.md +2026-08-17-durable-web-queue-recovery.md: c235cc47d5a68623249765753c2757948721ff6e +2026-08-17-durable-web-queue-recovery.zh.md: 2b224b5e1d63905e738dfda0772a3ea578c9aef3 diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md new file mode 100644 index 0000000000..c235cc47d5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.md @@ -0,0 +1,51 @@ +# Agent Note: Recover the Web queue from durable Inbox state + +Status: implemented + +English | [中文](2026-08-17-durable-web-queue-recovery.zh.md) + +## Problem + +Inbox acceptance records normalized `agent/inbox/spliced` events, but the Web queue used a separate mux baseline built by enumerating live Agents. After a Host process restart, a persisted ordinary Session remained cold until an operation needed its Agent, so the live-only baseline omitted accepted pending messages that were still present in the durable log. + +A reconnect-only repair would retain two recovery implementations: one for a live Inbox and one for cold Web reads. The correct owner is the Inbox domain, and the session-projection framework already provides live drive, cold folding, reconnect baselines, and cache restoration. + +## Decision + +When composed with the Session projection registry, `AgentLoop` registers the standard `inbox` projection at service activation so cold Sessions can be read without a live Agent. The [claimed Inbox lifecycle](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.md) owns splice normalization, message uniqueness, live notifications, and durable reconstruction. The projection shares one schema and `InboxState` definition; message values rely on the existing typed `UserMessage` contract rather than a second runtime message validator. + +The registry folds committed splices before `Session.append()` returns; each Agent's `ReactLoopInbox` command facade reads that same live state rather than keeping another fold. + +The generic session-projection carrier is the only Web transport. It sends higher-seq `session/projection` values, includes the complete values block in each `session.follow` opening snapshot, folds detached cold logs, and uses the projection cache when valid. There is no Host-owned `queue` projection, placement vocabulary, handoff list, dedicated queue frame, or live-Agent reconnect enumeration. + +A synchronous subscription to ready Host generations discards every retained projection value and watermark before refreshing queries and restarting the control stream, including cold Sessions absent from the process-local control baseline. The first control stream waits for generation readiness; a baseline cannot arrive before invalidation and then be erased by a later Cordis `connection/reset` notification. Observable faces retain their identities and subscriptions. A list request from an earlier generation cannot publish values or settle the current request; history and list values from the new generation may therefore establish a lower durable seq without losing to unpersisted state. Within a generation, all incoming baselines obey higher-seq-wins, so a delayed control baseline cannot remove or overwrite newer list or history values. + +The client Session binding retains `inbox` in its generic per-session projection store and does not copy it into `SessionSnapshot`. QueueDock reads `next-turn` directly. ChatView reads user-origin `next-step` messages directly and ignores injected context. Claiming removes a pending value through the durable splice; a later `user/message` is rendered through the ordinary conversation projection. + +`session.updateQueue` resolves an ordinary cold Session through the shared Agent resolver before mutating its Inbox. A restored pending row therefore remains editable, removable, or steerable after restart, while subagent ownership keeps the same fence as other Agent operations. + +No new session event or on-disk format is introduced. The existing splice stream remains the durable source of truth. + +## Verification + +Host projection coverage reads a detached persisted Session with pending input, returns `values.inbox` in the opening `session.follow` snapshot, and proves that no live Agent is required. Cold-operation coverage proves `session.updateQueue` resumes the Session and appends the durable removal splice. + +Client coverage pins generic Inbox projection delivery, reconnect invalidation for omitted cold Sessions, both baseline arrival orders, obsolete list request outcomes, higher-seq retention before Session materialization, and the absence of queue state from `SessionSnapshot`. UI coverage pins direct `next-turn` QueueDock rendering and user-origin `next-step` ChatView rendering. The keyless Web fixture opens a cold persisted Session and observes its pending row after restart. + +## Alternatives considered + +**Add cold Sessions to the old queue reconnect loop.** Rejected because it would duplicate the projection registry's cold fold and preserve separate implementations for live pushes, history, cache, and reconnect. + +**Register a Web-specific `queue` projection in Session Controller.** Rejected because pending input belongs to Inbox. Placement rows and a handoff list would introduce a second domain model solely for one client. + +**Store a complete Inbox snapshot on every splice event.** Rejected because the durable event is a normalized mutation, not a repeated aggregate. The projection framework owns aggregate reconstruction and checkpointing. + +**Reconstruct Inbox in the client from raw session events.** Rejected because pagination may omit the insertion that established current state and every client would duplicate splice semantics. + +**Resume every cold Agent while opening the mux stream.** Rejected because displaying durable state must not publish runtime resources, mount presets, or start lifecycle work. + +## Consequences + +Pending Queue and steering input recover after Host process restart without resuming an Agent. Live Inbox reads, cold history, reconnect, and projection caching use the same domain-owned fold and registry state. Operations on a restored row do resume its ordinary Agent, preserving preset composition and ownership checks. + +Clients receive the raw two-list Inbox value and decide which messages their surface presents. The projection state version invalidates cached rows whenever its serialized state or fold semantics change. diff --git a/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md new file mode 100644 index 0000000000..2b224b5e1d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-17-durable-web-queue-recovery.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 从持久 Inbox 状态恢复 Web Queue + +Status: implemented + +[English](2026-08-17-durable-web-queue-recovery.md) | 中文 + +## 问题 + +Inbox 接受消息时会记录规范化的 `agent/inbox/spliced` 事件,但 Web Queue 使用另一份通过枚举 live Agent 构建的 mux 基线。Host 进程重启后,持久化的普通 Session 会保持冷状态,直到某项操作需要其 Agent,因此 live-only 基线会遗漏仍存在于持久日志中的已接受待处理消息。 + +只修复重连逻辑仍会保留两套恢复实现:一套用于 live Inbox,另一套用于 Web 冷读取。正确的所有者是 Inbox 领域,而会话投影框架已经提供 live 驱动、冷折叠、重连基线和缓存恢复。 + +## 决策 + +组合了 Session projection registry 时,`AgentLoop` 在服务激活时注册标准 `inbox` 投影,使冷 Session 无需 live Agent 即可读取。[Inbox 认领生命周期](../architecture/2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md) 定义 splice 规范化、消息唯一性、live 通知和持久重建。投影共用一份 schema 与 `InboxState` 定义;消息值依赖既有的类型化 `UserMessage` 约定,而不增加第二套运行时消息校验器。 + +注册表在 `Session.append()` 返回前折叠已提交的 splice;每个 Agent 的 `ReactLoopInbox` 命令 facade 都读取同一份 live 状态,而不另行维护折叠状态。 + +通用会话投影传输层是唯一 Web 传输。它发送 seq 更高的 `session/projection` 值,在每次 `session.follow` 的起始快照中包含完整 values 块,折叠已分离的冷日志,并在缓存有效时使用投影缓存。系统不存在 Host 拥有的 `queue` 投影、placement 词汇、handoff 列表、专用 queue 帧或枚举 live Agent 的重连逻辑。 + +对已就绪 Host generation 的同步订阅会先丢弃所有保留的投影值及其水位,再刷新查询并重新打开 control stream,其中也包括进程本地 control baseline 中没有列出的冷 Session。首次 control stream 会等待 generation 就绪;baseline 不会先于旧状态清理到达,再被较晚的 Cordis `connection/reset` 通知清除。Observable face 保留自身标识及订阅。较早 generation 的 list 请求不能发布值或使当前请求结束,因此新 generation 的历史与 list 值可以建立较低的持久 seq,而不会被尚未持久化的状态挡住。同一 generation 内,所有收到的 baseline 都遵循较高 seq 优先,因此延迟到达的 control baseline 不能删除或覆盖较新的 list 或 history 值。 + +客户端 Session binding 在通用逐会话投影存储中保留 `inbox`,不会把它复制进 `SessionSnapshot`。QueueDock 直接读取 `next-turn`。ChatView 直接读取用户来源的 `next-step` 消息,并忽略注入上下文。认领操作通过持久 splice 移除待处理值;后续 `user/message` 由普通会话投影渲染。 + +`session.updateQueue` 在修改 Inbox 前通过共享 Agent 解析器解析普通冷 Session。因此,恢复出的待处理行在重启后仍可编辑、移除或 steering,而 subagent ownership 保持与其他 Agent 操作相同的 fence。 + +系统没有引入新的会话事件或磁盘格式。既有 splice 流仍是持久真源。 + +## 验证 + +Host 投影覆盖会读取包含待处理输入的已分离持久 Session,在 `session.follow` 的起始快照中返回 `values.inbox`,并证明不需要 live Agent。冷操作覆盖证明 `session.updateQueue` 会恢复 Session 并追加持久删除 splice。 + +客户端覆盖固定通用 Inbox 投影投递、重连时清理遗漏冷 Session 的旧值、基线的两种到达顺序、过期 list 请求的结果、Session 实例化前保留 seq 更高的值,以及 `SessionSnapshot` 不含 queue 状态。UI 覆盖固定 QueueDock 直接渲染 `next-turn`,以及 ChatView 渲染用户来源的 `next-step`。无密钥 Web fixture 会打开一份冷持久 Session,并在重启后观察其待处理行。 + +## 考虑过的替代方案 + +**把冷 Session 加入旧 queue 重连循环。** 不予采纳,因为这会重复投影注册表的冷折叠,并让实时推送、历史、缓存和重连继续使用不同实现。 + +**在 Session Controller 注册 Web 专属 `queue` 投影。** 不予采纳,因为待处理输入属于 Inbox。placement 行与 handoff 列表会只为一个客户端引入第二套领域模型。 + +**在每条 splice 事件中保存完整 Inbox 快照。** 不予采纳,因为持久事件是规范化变更,不是重复聚合。聚合重建与 checkpoint 属于投影框架。 + +**在客户端根据原始会话事件重建 Inbox。** 不予采纳,因为分页可能省略建立当前状态的插入事件,每个客户端也会重复实现 splice 语义。 + +**打开 mux 流时恢复每个冷 Agent。** 不予采纳,因为展示持久状态不应发布运行时资源、挂载 preset 或启动生命周期工作。 + +## 后果 + +待处理 Queue 与 steering 输入可在 Host 进程重启后恢复,而无需恢复 Agent。live Inbox 读取、冷历史、重连与投影缓存使用同一份领域拥有的折叠与注册表状态。操作恢复出的行时会恢复其普通 Agent,从而保留 preset 组合与所有权检查。 + +客户端接收原始的两列表 Inbox 值,并自行决定界面呈现哪些消息。投影的状态版本会在其序列化状态或折叠语义变化时使缓存行失效。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.i18n.yaml new file mode 100644 index 0000000000..ce0280aaa9 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.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-15-composer-paste-line-breaks.md +2026-09-15-composer-paste-line-breaks.md: 8411644c31b8b48dfd16ceb08815062bcb32f99f +2026-09-15-composer-paste-line-breaks.zh.md: beb2f5ea7a75dd24392f5c28e99189f186a557e4 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md new file mode 100644 index 0000000000..8411644c31 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.md @@ -0,0 +1,29 @@ +# Agent Note: Composer paste inserts line breaks + +Status: implemented + +English | [中文](2026-09-15-composer-paste-line-breaks.zh.md) + +## Problem + +A pasted multi-line draft and a typed one hold the same text in different documents. The composer's paste path inserted clipboard text through `RangeSelection.insertText`, which keeps every newline inside one text node, while typing and the editor's own programmatic writes produce a line-break node per line. + +Inside a single text node the browser reports the collapsed caret's geometry as the block's. With a pasted draft scrolled to its end, deleting the trailing line made Lexical's collapsed-caret reveal — `scrollIntoViewIfNeeded`, reached from `$updateDOMSelection` — read a zero-height rect at the top of the block and write that offset into the composer's scrollport. The capped box then showed the head of the draft with the caret off screen, in a draft that still overflowed. The same document also gave Home and vertical caret movement block boundaries instead of line boundaries. + +## Decision + +[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) inserts through `RangeSelection.insertRawText`, the path Lexical's own paste handler uses; its raw-text generator splits the input into text and line-break nodes. A pasted draft therefore carries the document a typed draft carries, and both project to the same draft text. + +## Alternatives considered + +**Keep `insertText` and correct the reveal in the composer view.** The composer's own `revealDraftSelection` never ran during the failing gesture: the write came from Lexical's selection reconciliation, which owns the caret reveal for a collapsed selection. Guarding that function would not have reached it, and suppressing Lexical's reveal would remove the caret tracking ordinary typing depends on. + +**Rewrite newlines inside the pasted text.** Normalizing the clipboard text before insertion leaves the document unchanged — the defect is the node structure the insertion produces, not the characters it receives. + +## Consequences + +Pasted newlines become line-break nodes instead of characters inside a text node. The clipboard projection, and with it the submitted prompt, the draft mirror, and trigger spans, is unchanged: `$composerLayout` projects a line-break node and a paragraph gap alike as one `\n`. Tab characters reach `TabNode` through the same raw-text path. The composer no longer relies on the browser reporting per-line geometry for a draft stored in one text node. + +## Testing + +[The composer scroll scenario](../../../../apps/web/tests/composer-draft-scroll.e2e.ts) pins the behavior in a real browser: a pasted draft with a blank tail, scrolled to its end, holds `scrollTop === scrollMax` through an end-of-draft Backspace and through a selection delete of the trailing lines. Reverting `paste()` to `insertText` fails that case on `scrollTop === scrollMax` while the draft still overflows; the scenario's typed-draft cases and its committed geometry golden pass with either insertion path. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md new file mode 100644 index 0000000000..beb2f5ea7a --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-composer-paste-line-breaks.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Composer 粘贴插入换行节点 + +Status: implemented + +[English](2026-09-15-composer-paste-line-breaks.md) | 中文 + +## 问题 + +粘贴的多行草稿与逐行打字的草稿文本相同,但文档不同。Composer 的粘贴路径经 `RangeSelection.insertText` 插入剪贴板文本,会把每个换行都留在同一个文本节点内;而打字和编辑器自身的程序化写入会为每行产生一个换行节点。 + +在单个文本节点内,浏览器把折叠光标的几何信息报告为整个块的几何信息。当粘贴草稿滚动到末尾时,删掉末尾那行会让 Lexical 自己的折叠光标 reveal(`scrollIntoViewIfNeeded`,由 `$updateDOMSelection` 到达)读到一个位于块顶部的零高度矩形,并把该偏移写进 composer 的滚动容器。于是这个带高度上限的框显示出草稿开头、光标落在屏幕外,而草稿本身仍然溢出。同一份文档还让 Home 键与纵向光标移动拿到块级边界,而不是行级边界。 + +## 决策 + +[`DraftEditorRuntime.paste()`](../../../../packages/client/ui-conversation/src/client/input/editor/runtime.ts) 改经 `RangeSelection.insertRawText` 插入,这也是 Lexical 自身粘贴处理器所用的路径;它的 raw-text 生成器把输入拆成文本节点与换行节点。因此粘贴草稿与打字草稿携带相同的文档,两者投影出相同的草稿文本。 + +## 考虑过的替代方案 + +**保留 `insertText`,在 composer 视图里修正 reveal。** 失败手势中 composer 自己的 `revealDraftSelection` 从未运行:那次写入来自 Lexical 的选区对账,折叠选区的光标 reveal 归它所有。给该函数加守卫无法触及它,而屏蔽 Lexical 的 reveal 又会去掉普通打字所依赖的光标跟随。 + +**在粘贴文本内重写换行。** 插入前规范化剪贴板文本不会改变文档——缺陷在于插入产生的节点结构,而不在于它接收的字符。 + +## 影响 + +粘贴的换行成为换行节点,而不再是文本节点内的字符。剪贴板投影、并因此提交的 prompt、草稿镜像和触发词跨度都不变:`$composerLayout` 把换行节点与段落间隙同样投影为一个 `\n`。制表符经同一条 raw-text 路径落到 `TabNode`。composer 不再依赖浏览器为「整篇存于一个文本节点」的草稿报告逐行几何信息。 + +## 测试 + +[Composer 滚动场景](../../../../apps/web/tests/composer-draft-scroll.e2e.ts)在真实浏览器里固定该行为:尾部为空行的粘贴草稿滚动到末尾后,在草稿末尾按退格、以及选中末尾若干行后删除,都保持 `scrollTop === scrollMax`。把 `paste()` 退回 `insertText` 会让该用例在草稿仍然溢出时于 `scrollTop === scrollMax` 上失败;该场景中的打字草稿用例与已提交的几何 golden 在两种插入路径下都通过。 diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.i18n.yaml new file mode 100644 index 0000000000..e63305ebf1 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.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-15-messages-v1-base-url.md +2026-09-15-messages-v1-base-url.md: 97f7294c28988e6f115963033cdac6794d70d6f3 +2026-09-15-messages-v1-base-url.zh.md: ccb8c5930dbbfa6274770cc03087b4d8077fe27b diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md new file mode 100644 index 0000000000..97f7294c28 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.md @@ -0,0 +1,25 @@ +# Agent Note: Exact v1 recognition for Messages base URLs + +Status: implemented + +English | [中文](2026-09-15-messages-v1-base-url.zh.md) + +## Problem + +The Messages transport appends the Anthropic-standard `/v1` namespace to a configured base URL. A base that already ends in `/v1` previously produced `/v1/v1/messages`, while recognizing every `v`-plus-digit suffix as a provider version granted undocumented compatibility and could bypass the standard namespace. + +## Decision + +The shared Messages API owner trims trailing slashes and treats only a final path segment exactly equal to `v1` as the existing API version. It preserves that root and appends `/messages` or `/files`; every other base receives `/v1/messages` or `/v1/files`. The same resolved root scopes cached file uploads. The official `https://api.deepseek.com/anthropic` base therefore resolves to `/anthropic/v1`, and an explicit `/anthropic/v1` base remains unchanged. + +Chat Completions retains its independent URL behavior. The Messages rule does not infer support for `v1beta`, `v2`, `v4`, or other version-like suffixes; deployments that include those segments receive the standard `/v1` namespace beneath them. + +## Alternatives considered + +**Recognize any final segment beginning with `v` and a digit.** This avoids repetition for more custom endpoints, but it turns a narrow duplicate-`v1` repair into an undocumented compatibility policy and can route requests outside the Anthropic-standard namespace. + +**Always append `/v1`.** This follows the standard path for unversioned roots but preserves the original duplicate path for callers whose configured base already ends in `/v1`. + +## Consequences + +Messages, Files, and file-cache identity use one deterministic rule. Exact `/v1` configurations remain compatible without changing the recommended unversioned base. Other version-like suffixes are not treated as API versions and therefore resolve beneath an added `/v1`; this deliberately gives up speculative proxy compatibility. diff --git a/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md new file mode 100644 index 0000000000..ccb8c5930d --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-15-messages-v1-base-url.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Messages 基址严格识别 v1 + +Status: implemented + +[English](2026-09-15-messages-v1-base-url.md) | 中文 + +## 问题 + +Messages 传输会在配置的基址后追加 Anthropic 标准 `/v1` 命名空间。此前,已经以 `/v1` 结尾的基址会生成 `/v1/v1/messages`;而把所有 `v` 加数字的后缀都识别为提供方版本,会提供未经说明的兼容性,并可能绕过标准命名空间。 + +## 决策 + +共享 Messages API 所有者移除末尾斜线,仅把严格等于 `v1` 的最后路径段视为已有 API 版本。它保留该根地址并追加 `/messages` 或 `/files`;其他基址均追加 `/v1/messages` 或 `/v1/files`。缓存文件上传也使用同一个解析后的根地址划分作用域。因此,官方 `https://api.deepseek.com/anthropic` 基址解析为 `/anthropic/v1`,显式 `/anthropic/v1` 基址保持不变。 + +Chat Completions 保留独立的 URL 行为。Messages 规则不会推断对 `v1beta`、`v2`、`v4` 或其他版本式后缀的支持;包含这些路径段的部署会在其下获得标准 `/v1` 命名空间。 + +## 考虑过的替代方案 + +**识别所有以 `v` 加数字开头的末尾路径段。** 这可以避免更多自定义端点重复版本,但会把范围有限的 `v1` 重复修复变成未经说明的兼容策略,并可能把请求路由到 Anthropic 标准命名空间之外。 + +**始终追加 `/v1`。** 这对无版本根地址遵循标准路径,但仍会为已以 `/v1` 结尾的配置产生原有重复路径。 + +## 结果 + +Messages、Files 与文件缓存标识使用同一条确定性规则。严格匹配 `/v1` 的配置保持兼容,推荐的无版本基址无需改变。其他版本式后缀不被视为 API 版本,因此会在其下追加 `/v1`;这会有意放弃对代理的推测性兼容。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml index 1ee7b9dfc3..8cf2e53343 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.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-08-web-background-job-display.md -2026-08-08-web-background-job-display.md: 8da6c2fd914bf07cfa7d3545cff1e42552c69d27 -2026-08-08-web-background-job-display.zh.md: 0e05ef9d2fcd8193c661f471b5f7b9a84891f98a +2026-08-08-web-background-job-display.md: 4b8396c1c0504ddf8494a03c22b9af094c4eed17 +2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md index 8da6c2fd91..4b8396c1c0 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md @@ -81,7 +81,7 @@ Four rules the carrier keeps: `SessionListState` carries `jobsBySession: Readonly>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation. -It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. +It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, no composer behavior depends on tasks, and a later sidebar indicator gets the data without opening a second channel. Two replacement points keep it honest. Each control-stream generation clears the complete jobs mirror before installing the new baseline's non-empty sets. An `api-session/removed` event also drops that Session's entry, independently of the job-registry disposal notification's ordering. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md index 0e05ef9d2f..03a43b87c4 100644 --- a/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md @@ -81,7 +81,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void `SessionListState` 带有 `jobsBySession: Readonly>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。 -它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 +它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有 composer 行为依赖任务;将来侧栏加指示器时不必再开第二条通道。 两个替换点让它保持诚实。每一代 control 流都会先清空完整任务镜像,再安装新 baseline 中的非空集合。`api-session/removed` 事件也会删除该 Session 的条目,不依赖任务注册表 disposal 通知与它之间的顺序。 diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml index 81ba63095f..bee006748b 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.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-09-07-deepseek-messages-adapter.md -2026-09-07-deepseek-messages-adapter.md: 9a92f9e95931bebfa9fa6e7e64fbd7d27ec8306c -2026-09-07-deepseek-messages-adapter.zh.md: 67f6c97c0a416cbddb7ec9d0de01e47e658036b7 +2026-09-07-deepseek-messages-adapter.md: 6b7aff250aacd4bb7d0af3b4e68ee5b2002c13e3 +2026-09-07-deepseek-messages-adapter.zh.md: 829e2189cb48a2acaa1ec27f7ccade0f163b58ad diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md index 9a92f9e959..6b7aff250a 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.md @@ -16,11 +16,11 @@ The adapter follows the [DeepSeek compatibility documentation](https://api-docs. Assistant blocks remain the durable model-visible content. A versioned `ReplayEnvelope` stores only the protocol format, model identity, aligned block kinds, and signatures absent from those blocks. Same-model Messages continuation restores signatures verbatim, including empty signatures; foreign history carries no invented signature. Unusable metadata follows the existing [replay degradation rule](../architecture/2026-07-14-provider-routed-llm-adapters.md): the request omits signatures with a warning while preserving durable content; content validation such as tool argument parsing still fails explicitly. This keeps provider replay data opaque to the loop while preserving it through Session persistence and block pruning. -Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages uses `/v1/files` with its required beta header, while Chat Completions uses `/files`. Cached ids remain scoped by configured endpoint and credential. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. +Both protocols prefer Files references for deterministic request images and share upload caching, refresh, quota recovery, and attachment offload. The Files client retains the selected protocol and configured endpoint: Messages follows the [exact `/v1` root rule](../bug-fix/2026-09-15-messages-v1-base-url.md), while Chat Completions appends `/files`. Messages Files requests carry the required beta header. Cached ids remain scoped by the resolved Files root and credential, so equivalent `/v1` and unversioned Messages roots share uploads. Messages metadata omits expiry, so local reuse is bounded from the original upload time without asserting remote deletion. A Files-resolution failure rebuilds the complete request under the independent inline-image budget; caller cancellation stops it. The shared image policy preserves the 128 MiB retained-image budget, 20 MiB inline base64 budget, and oldest-prefix offload in both requests and token measurement. System updates use the existing [route capability](2026-09-02-in-history-system-prompt-replacement.md) when explicitly declared for an endpoint/model. Messages retains the initial top-level system and emits later snapshots as native system turns after the corresponding user/tool-result turn, preserving previously sent prefixes. This placement differs from the loop's system-before-user admission; serialization changes neither the durable log nor conversation-turn order. Undeclared routes consolidate the latest snapshot at the top level, including direct compaction calls. Capability inference from protocol or model names is insufficient because support and update semantics depend on the deployed endpoint. -Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default; a custom `baseURL` or environment override is never rewritten to match a protocol. +Web always displays DeepSeek without a protocol selector. Both protocols share `baseURL` and `apiKeyEnv`, with no nested per-protocol configuration map. Without an endpoint override, resolution uses the selected protocol’s official default; Messages uses `https://api.deepseek.com/anthropic`. Switching retains existing endpoint overrides, whose compatibility belongs to the deployment. Messages follows the exact `/v1` root rule rather than inferring compatibility from other version-like suffixes. One model catalog includes `deepseek-flash` text/image and in-history system capabilities and retains the V4 entries. Explicit `chat-completions` remains supported with its own official default and appends `/chat/completions` without adding a version segment. Both transports use the existing [request-extension registry](../architecture/2026-08-21-deepseek-llm-api-request-extensions.md) after native serialization and accept captured contributions after HTTP 2xx, before reading the stream. Session-log delivery and plugin inventory retain their owners and remain outside model input. The auxiliary [web-search provider](../../../../packages/web/web-search-deepseek/README.md) retains its separate endpoint, request, and settings. diff --git a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md index 67f6c97c0a..829e2189cb 100644 --- a/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md +++ b/.agents/notes/implemented/feature/2026-09-07-deepseek-messages-adapter.zh.md @@ -16,11 +16,11 @@ Status: implemented 助手内容块保留持久化的模型可见内容。带版本的 `ReplayEnvelope` 仅保存协议格式、模型标识、对齐的块类型以及内容块未包含的签名。同模型续接原样恢复签名,包括空签名;外部历史不生成虚构签名。不可用的元数据遵循现有[回放降级规则](../architecture/2026-07-14-provider-routed-llm-adapters.zh.md):请求省略签名并记录警告,保留持久化内容;工具参数等内容校验仍会正常报错。提供者回放数据对循环保持不透明,同时能够随 Session 持久化和内容块裁剪保留。 -两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 使用 `/v1/files` 并携带必需的 beta 标头,Chat Completions 使用 `/files`。缓存 id 仍按配置的端点和凭据限定作用域。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 +两种协议均优先为确定性请求图片使用 Files 引用,并共享上传缓存、刷新、配额恢复和附件卸载。Files 客户端保留所选协议与已配置端点:Messages 遵循[严格匹配 `/v1` 的根地址规则](../bug-fix/2026-09-15-messages-v1-base-url.zh.md),Chat Completions 则追加 `/files`。Messages Files 请求携带必需的 beta 标头。缓存 id 按解析后的 Files 根地址和凭据限定作用域,因此等价的 `/v1` 与无版本 Messages 根地址可以复用上传。Messages 元数据不含过期时间,因此本地复用从原始上传时间起受限,但不宣称远端文件已删除。Files 解析失败会按独立的内联图片预算重建完整请求;调用方取消则停止请求。共享图片策略在请求与 token 计量中保留 128 MiB 的保留图片预算、20 MiB 的内联 base64 预算,以及最旧前缀卸载。 系统提示词更新在端点与模型显式声明支持时,使用现有[路由能力](2026-09-02-in-history-system-prompt-replacement.zh.md)。Messages 保留初始顶层 system,在对应的用户或工具结果轮次之后,将后续快照发送为原生 system 轮次,保留此前发送的前缀。这个位置不同于循环先 system、后 user 的接纳顺序;序列化既不改写持久化日志,也不改变对话轮次的顺序。未声明能力的路由将最新快照归并到顶层,直接压缩调用也如此。仅凭协议或模型名称推断能力并不充分,因为支持情况和更新语义取决于实际部署的端点。 -Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值;不会为匹配协议而改写自定义 `baseURL` 或环境覆盖。 +Web 始终显示 DeepSeek,不提供协议选择器。两个协议共用 `baseURL` 与 `apiKeyEnv`,没有嵌套的协议配置表。未提供地址覆盖时使用当前协议的官方默认值;Messages 为 `https://api.deepseek.com/anthropic`。切换协议保留已有端点覆盖,部署者负责其兼容性。Messages 遵循严格匹配 `/v1` 的根地址规则,不根据其他版本式后缀推断兼容性。模型目录只维护一份,包含 `deepseek-flash` 的文本/图片和历史内 system 更新能力,也保留 V4 条目。显式 `chat-completions` 仍受支持,并使用自己的官方默认值,追加 `/chat/completions` 而不增加版本段。 两种传输都在原生序列化后使用现有[请求扩展注册表](../architecture/2026-08-21-deepseek-llm-api-request-extensions.zh.md),并在 HTTP 2xx 后、读取流之前接受已捕获贡献。会话日志投递和插件清单仍由原有包负责,并留在模型输入之外。辅助 [web 搜索提供方](../../../../packages/web/web-search-deepseek/README.zh.md)保留独立的端点、请求与设置。 diff --git a/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.i18n.yaml new file mode 100644 index 0000000000..a0663094ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.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-14-model-image-input-settings.md +2026-09-14-model-image-input-settings.md: ef328400332aa58c02a450ead0195eff124c5fdf +2026-09-14-model-image-input-settings.zh.md: 70ec427b138026124cad2ffdb1fc5f60597abe8a diff --git a/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md new file mode 100644 index 0000000000..ef32840033 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.md @@ -0,0 +1,27 @@ +# Agent Note: Model image-input settings + +Status: implemented + +English | [中文](2026-09-14-model-image-input-settings.zh.md) + +## Problem + +Models settings can edit a model id without exposing the input capabilities that determine whether image attachments are accepted. A custom vision model can therefore appear in the picker while retaining a text-only declaration. + +## Decision + +Each model row exposes image input under Model options. Supported declares text and image; Not supported declares text only; Default removes the model's explicit input field. DeepSeek writes `inputModalities`, whose absent value means text only. Pi-ai writes `input`, whose absent or empty value inherits the installed model catalog or provider default. Opening a row preserves that inheritance without materializing an override. + +The shared field replaces one drafted row and preserves unrelated metadata. Selecting text only or default for DeepSeek also removes its image request limits, because the adapter rejects those limits without image input. Saving uses the existing catalog-array settings mutation and adapter validation. Configuration declares an upstream capability; it does not add image processing to a text-only model. + +## Alternatives considered + +**Keep `input` editable only in the settings document.** The [earlier pi-ai modality decision](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md) kept this field outside the model-list editor. That leaves users who add custom vision models through the UI unable to enable their image input there. Per-row editing supplies that configuration while the default choice preserves catalog inheritance. + +**A two-state switch.** Treating an absent pi-ai declaration as disabled would misrepresent inherited vision support and encourage overwriting catalog defaults. The explicit Default choice preserves the adapter's existing resolution rules. + +**Keep image limits when disabling DeepSeek images.** This leaves a configuration that the adapter refuses to save. Clearing the image-specific limits makes the selected text-only state valid while preserving unrelated model fields. + +## Consequences + +Users can configure image input for DeepSeek and custom pi-ai model rows through the same control. Restoring defaults can change effective capabilities when the installed catalog or provider defaults change. DeepSeek image limits must be configured again after disabling images. Provider routing and the [catalog recovery rules](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.md) remain owned by their existing decisions. diff --git a/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md new file mode 100644 index 0000000000..70ec427b13 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-14-model-image-input-settings.zh.md @@ -0,0 +1,27 @@ +# Agent Note:模型图片输入设置 + +Status: implemented + +[English](2026-09-14-model-image-input-settings.md) | 中文 + +## 问题 + +模型设置可以编辑模型 ID,却没有展示决定图片附件是否被接受的输入能力。因此,自定义视觉模型虽然出现在选择器中,仍可能保留仅文本的声明。 + +## 决策 + +每个模型行在「模型选项」下提供图片输入设置。「支持」声明文本和图片;「不支持」声明仅文本;「默认」移除模型的显式输入字段。DeepSeek 写入 `inputModalities`,缺省时表示仅文本。Pi-ai 写入 `input`,缺省或空数组时继承已安装模型目录或提供方默认值。打开模型行保留这种继承,不会生成覆盖值。 + +共享字段替换一个草稿模型行并保留无关元数据。DeepSeek 选择仅文本或默认时,还会移除图片请求限制,因为适配器在没有图片输入时拒绝这些限制。保存使用现有的模型目录数组设置变更和适配器校验。配置声明上游能力,不会为仅文本模型增加图片处理能力。 + +## 考虑过的替代方案 + +**仅允许在设置文档中编辑 `input`。** [早期 pi-ai 输入模态决策](../../archived/architecture/2026-08-12-pi-ai-route-default-input-modalities.md)将该字段留在模型列表编辑器之外。这使通过 UI 添加自定义视觉模型的用户无法在同一界面启用图片输入。逐行编辑提供了该配置,而默认选项保留模型目录继承。 + +**两态开关。** 将缺省的 pi-ai 声明视为禁用,会错误表达继承的视觉能力,并促使用户覆盖模型目录的默认值。显式「默认」选项保留适配器现有的解析规则。 + +**禁用 DeepSeek 图片时保留图片限制。** 这会留下适配器拒绝保存的配置。清除图片专属限制,使选择的仅文本状态有效,同时保留无关模型字段。 + +## 影响 + +用户可以通过相同控件配置 DeepSeek 和自定义 pi-ai 模型行的图片输入。恢复默认值后,有效能力可能随已安装模型目录或提供方默认值变化。禁用图片后,DeepSeek 图片限制需要重新配置。提供方路由和[模型目录恢复规则](../bug-fix/2026-09-07-pi-ai-settings-catalog-recovery.zh.md)仍由现有决策负责。 diff --git a/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.i18n.yaml b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.i18n.yaml new file mode 100644 index 0000000000..2ef9ed7383 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.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-15-profile-command-shorthand.md +2026-09-15-profile-command-shorthand.md: 15bdf30b98eb814e299e8e2c3af3756b1f59aad1 +2026-09-15-profile-command-shorthand.zh.md: 77ffd2324fc8fe518368fce44f52ad85274eb7b5 diff --git a/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md new file mode 100644 index 0000000000..15bdf30b98 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.md @@ -0,0 +1,25 @@ +# Agent Note: Profile command shorthand + +Status: implemented + +English | [中文](2026-09-15-profile-command-shorthand.zh.md) + +## Problem + +Profile launch needs a concise spelling that works for custom names without making plugin management depend on the contents of the Harness home. + +## Decision + +The CLI expands a leading non-option argument other than `plugin` into `--profile ` before parsing. Both spellings use the same launcher flags, app-argument forwarding, and profile validation. `plugin` retains command priority only as the first argument; `dsh --profile plugin` selects the same-named profile explicitly. After profile selection, `plugin` is forwarded as an app argument. Repeated profile selection before app arguments is rejected. + +This decision supersedes the Web-only shorthand mechanism in [one dsh application launcher](../architecture/2026-08-22-single-dsh-application-launcher.md); that note retains authority over application composition and lifecycle ownership. + +## Alternatives considered + +- Registering profiles as commands requires filesystem discovery and makes parsing depend on installed profiles. +- Giving profiles priority over built-in commands makes installing a profile change the meaning of plugin-management invocations. +- Last-wins profile selection can launch a different app from the leading name; explicit rejection avoids that ambiguity. + +## Consequences + +Custom profiles and shipped profiles share one shorthand without adding public types. Names must immediately follow `dsh`; an unknown name reaches the existing missing-profile diagnostic. Removing the dedicated `web` command also lets an already selected profile receive `web` as an app argument. Parser equivalence tests, built-bin acceptance, and the keyless headless tool round trip cover the shared launch path. diff --git a/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md new file mode 100644 index 0000000000..77ffd2324f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-15-profile-command-shorthand.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Profile 命令简写 + +Status: implemented + +[English](2026-09-15-profile-command-shorthand.md) | 中文 + +## Problem + +Profile 启动需要一种适用于自定义名称的简洁写法,同时不能让插件管理依赖 Harness home 中的内容。 + +## Decision + +CLI 在解析前,将开头非选项且非 `plugin` 的参数展开为 `--profile `。两种写法使用相同的启动器 flag、应用参数透传和 profile 校验。`plugin` 仅在首个参数位置保持命令优先级;`dsh --profile plugin` 显式选择同名 profile。选定 profile 后,`plugin` 作为应用参数透传。应用参数开始之前,重复选择 profile 会被拒绝。 + +本决策取代[统一 dsh 应用启动器](../architecture/2026-08-22-single-dsh-application-launcher.zh.md)中仅为 Web 提供简写的机制;该 Note 继续负责应用组合与生命周期的所有权。 + +## Alternatives considered + +- 将 profile 注册为命令需要扫描文件系统,并使解析依赖已安装的 profile。 +- 让 profile 优先于内置命令,会使安装 profile 改变插件管理调用的含义。 +- 让后一次 profile 选择覆盖前一次,可能启动与开头名称不同的应用;显式拒绝可避免这种歧义。 + +## Consequences + +自定义和内置 profile 共用一种简写,无需新增公开类型。名称必须紧跟 `dsh`;未知名称会触发现有的 profile 缺失诊断。移除专用的 `web` 命令后,已选定的 profile 也能将 `web` 作为应用参数接收。解析等价性测试、构建产物验收和无密钥 headless 工具往返场景覆盖共用的启动路径。 diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml b/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml index 8519ec4470..d5cb5514e9 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.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/feature/2026-08-04-task-surface.md -2026-08-04-task-surface.md: d5d04a7ac8aee9936e1913430c2efa6e6c005206 -2026-08-04-task-surface.zh.md: ecba9ef7f9b45bb9f12673c30d9a9b47fb93a30e +2026-08-04-task-surface.md: 749ce9e40eea7aeb7a813dc6b2d4c139cf00ac44 +2026-08-04-task-surface.zh.md: 4a63e671ffe05c80dd8bc8ed3a49ab6ff5c44b7e diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.md index d5d04a7ac8..749ce9e40e 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.md @@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource { } ``` -The `session/queue` wire item already carries the complete `Message`. The client projection is explicitly extended to retain its source instead of dropping the correlation: - -```ts ignore-check -interface QueuedMessage { - id: InboxItemId - messageId: MessageId - placement: 'queued' | 'steering' - source: MessageSource - content: readonly ContentBlock[] - preview: string - text: string | null -} -``` +The standard `inbox` projection already carries each complete `UserMessage`, including its `MessageId`, source, and content, in the raw `next-turn` or `next-step` list. The client retains that value in its generic projection store, so this proposal needs no queue transport extension or second pending-message type. The browser-safe domain package owns `TaskSurfaceId`, the submission and dismissal IDs, `TaskSurfaceCorrelation`, and the pending-submission shape. ApiProxy owns the transport augmentation that combines the correlation with `rpcId`. Keeping `kind: 'user'` preserves the ordinary user bubble and prompt semantics while the extra field provides durable correlation. The message content is a product-formatted readable summary: panel title, labels and submitted values, plus the optional note. The model receives that same text. The structured source is not a second hidden instruction. @@ -198,7 +186,7 @@ The product shell owns collapse and dismiss. Collapse is local view state and se Submission is transactional at the client boundary. Acceptance returns the exact `messageId` in phase `queued`; the Dock disables every mutation through both `queued` and `claiming` and clears the persisted draft only after the matching user message becomes durable. A rejection keeps the values editable and shows the returned reason. Double clicks and transport retries reuse `submissionId` and return the first result; another submission ID receives `submission-pending` while the first is live. The Host admits one user message for one accepted Surface. -The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent dequeues that occurrence for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before ApiProxy publishes the ordinary queue snapshot without the claimed row. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard. +The Task Surface service records accepted submission coordination as `pending.phase: 'queued'`, while the client can correlate the still-present queue row through its retained `source`. When the Agent claims that message for ordinary prompt admission, the service synchronously changes the same pending record to `claiming` before the durable deletion splice removes it from the generic `inbox` projection. The service keeps that process-local claim across asynchronous admission and reconnect until a matching durable `user/message` is published or the Agent reports a terminal discard. The matching `user/message` closes the durable projection and clears the claim. Rejection, cancellation, or disposal before durability reports the discard, clears the claim, and leaves the Surface open. The Dock never interprets queue-row disappearance as either outcome: it re-reads `getActive`; `pending.phase: 'claiming'` stays disabled, `pending: null` restores the draft, and `not-open` closes the Dock. `getActive` joins the log-derived active occurrence with this one process-local pending record. The record is coordination state, not a second durable authority; after a Host restart, an uncommitted claim is absent and the still-open logged Surface becomes editable again. diff --git a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md index ecba9ef7f9..4a63e671ff 100644 --- a/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md +++ b/.agents/notes/proposed/feature/2026-08-04-task-surface.zh.md @@ -178,19 +178,7 @@ interface TaskSurfaceUserMessageSource { } ``` -`session/queue` 协议条目已经携带完整 `Message`。客户端投影会显式扩展以保留其来源,不再丢失关联信息: - -```ts ignore-check -interface QueuedMessage { - id: InboxItemId - messageId: MessageId - placement: 'queued' | 'steering' - source: MessageSource - content: readonly ContentBlock[] - preview: string - text: string | null -} -``` +标准 `inbox` 投影已经在原始 `next-turn` 或 `next-step` 列表中携带每条完整 `UserMessage`,包括 `MessageId`、source 与 content。客户端会把该值保存在通用 projection store 中,因此本提案不需要扩展 queue 传输,也不需要第二种待处理消息类型。 浏览器安全的领域包拥有 `TaskSurfaceId`、提交和关闭 ID、`TaskSurfaceCorrelation`,以及待处理提交的形态。ApiProxy 拥有传输扩展,负责将关联信息与 `rpcId` 组合。保留 `kind: 'user'` 可维持普通用户消息气泡和提示词语义,额外字段则提供持久关联信息。消息内容是由产品格式化的可读摘要,包括面板标题、标签和提交值,以及可选备注。模型接收相同的文本。结构化来源不是第二条隐藏指令。 @@ -198,7 +186,7 @@ interface QueuedMessage { 客户端边界上的提交具有事务性。接纳成功会返回处于 `queued` 阶段的确切 `messageId`;在 `queued` 和 `claiming` 两个阶段中,Dock 会禁用所有变更,并且只有匹配的用户消息持久化后,才会清除已持久化的草稿。若请求被拒绝,则保留值供用户继续编辑,并显示返回的原因。双击和传输重试会复用 `submissionId` 并返回第一次调用的结果;只要第一次提交仍在处理中,另一个提交 ID 就会收到 `submission-pending`。对于一个已接受的 Surface,Host 只会接纳一条用户消息。 -Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 从队列取出该调用实例进行普通提示词接纳时,服务会先同步把同一份待处理记录改为 `claiming`,然后 ApiProxy 才发布不再包含已认领行的普通队列快照。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。 +Task Surface 服务将已接受提交的协调状态记录为 `pending.phase: 'queued'`,客户端则可通过仍在队列中的行所保留的 `source` 关联它。当 Agent 为普通提示词接纳认领该消息时,服务会先同步把同一份待处理记录改为 `claiming`,随后持久删除 splice 才会从通用 `inbox` 投影移除该消息。服务会在异步接纳和重新连接期间一直保留这份进程内认领状态,直到匹配的持久 `user/message` 发布,或 Agent 报告终态丢弃。 匹配的 `user/message` 会关闭持久投影并清除认领状态。在持久化之前发生拒绝、取消或 dispose(资源释放)时,系统会报告丢弃、清除认领状态,并让 Surface 保持打开。Dock 绝不会把队列行消失解读为其中任一结果,而会重新读取 `getActive`:`pending.phase: 'claiming'` 会维持禁用状态,`pending: null` 会恢复草稿,`not-open` 会关闭 Dock。`getActive` 会把由日志推导的活动调用实例与这唯一一份进程内待处理记录合并。该记录属于协调状态,不是第二个持久权威来源;Host 重启后,未提交的认领状态不复存在,日志中仍然打开的 Surface 会恢复为可编辑状态。 diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index be5b6d4cbe..5e16f957c3 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -17,9 +17,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status. -Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author and reviewers without write permission do not count. +Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count. -A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when base approval points already meet the threshold or a blocking review exists. Displayed scores use at most two decimal places; the decision uses the unrounded score. The curve endpoints come from the policy’s default and required points. +The PR author contributes `min(0.6, mergedPRCount / 250)` points: 0 merged PRs → 0 points, 100 → 0.4, and 150 or more → 0.6. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 150 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error. + +A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points. Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the Desktop renderer, Python interpreter scripts, and committed runtime/packer launchers. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support (including `src/testing/` and `src/testing.ts`), examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame. @@ -31,7 +33,7 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea ## Security -All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, base points are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs. +All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs. Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run. @@ -39,7 +41,7 @@ Approval policy changes take effect only after they merge into the default branc ## Verification -Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration. +Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration. diff --git a/.github/review-ownership/author-weight.mjs b/.github/review-ownership/author-weight.mjs new file mode 100644 index 0000000000..b76f5525fa --- /dev/null +++ b/.github/review-ownership/author-weight.mjs @@ -0,0 +1,62 @@ +/** Author credit from merged pull requests in the same repository. */ +const CAP_COUNT = 150 + +/** + * Convert merged PR count to author points, capped at 0.6. + * @param {number} mergedCount Merged PR count. + * @returns {number} Author approval points. + */ +export function authorCreditPoints(mergedCount) { + return Math.min(CAP_COUNT, mergedCount) / 250 +} + +/** + * Count merged PRs by immutable author account, stopping at the credit cap. + * @param {{repository: string, number: number, authorId: string}} pull Current pull request. + * @param {(path: string, options: object) => Promise} api GitHub API caller. + * @returns {Promise} Merged count, capped at 150; incomplete responses reject. + */ +export async function countMergedAuthorPulls(pull, api) { + const [owner, name] = pull.repository.split('/') + const seen = new Set() + const cursors = new Set() + let after = null + let count = 0 + do { + const response = await api('/graphql', { + method: 'POST', + body: { + query: `query($owner: String!, $name: String!, $after: String) { + repository(owner: $owner, name: $name) { + pullRequests(states: MERGED, first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { number author { ... on Node { id } } } + pageInfo { hasNextPage endCursor } + } + } + }`, + variables: { owner, name, after }, + }, + }) + const connection = response.data?.repository?.pullRequests + if (response.errors?.length || !Array.isArray(connection?.nodes) + || typeof connection.pageInfo?.hasNextPage !== 'boolean') { + throw new Error('GitHub merged PR history is incomplete') + } + for (const entry of connection.nodes) { + if (!Number.isSafeInteger(entry?.number) || entry.number <= 0 + || (entry.author !== null && typeof entry.author?.id !== 'string')) { + throw new Error('GitHub returned an invalid merged PR') + } + if (seen.has(entry.number)) continue + seen.add(entry.number) + if (entry.number !== pull.number && entry.author?.id === pull.authorId) count++ + if (count === CAP_COUNT) return count + } + if (!connection.pageInfo.hasNextPage) return count + after = connection.pageInfo.endCursor + if (typeof after !== 'string' || !after || cursors.has(after)) { + throw new Error('GitHub merged PR history cursor did not advance') + } + cursors.add(after) + } while (true) +} diff --git a/.github/review-ownership/author-weight.test.mjs b/.github/review-ownership/author-weight.test.mjs new file mode 100644 index 0000000000..1b648e54d0 --- /dev/null +++ b/.github/review-ownership/author-weight.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { countMergedAuthorPulls } from './author-weight.mjs' + +const pull = { repository: 'owner/repo', number: 999, authorId: 'account-id' } +const entry = (number, id = pull.authorId) => ({ number, author: id === null ? null : { id } }) +const page = (nodes, hasNextPage = false, endCursor = null) => ({ + data: { repository: { pullRequests: { nodes, pageInfo: { hasNextPage, endCursor } } } }, +}) + +test('counts only the same account and excludes the current PR and deleted accounts', async () => { + const count = await countMergedAuthorPulls(pull, async (path, { method, body }) => { + assert.equal(path, '/graphql') + assert.equal(method, 'POST') + assert.deepEqual(body.variables, { owner: 'owner', name: 'repo', after: null }) + assert.match(body.query, /pullRequests\(states: MERGED, first: 100, after: \$after/u) + assert.match(body.query, /author \{ \.\.\. on Node \{ id \} \}/u) + return page([entry(1), entry(2, 'another-id'), entry(3, null), entry(999)]) + }) + assert.equal(count, 1) +}) + +test('follows cursors, deduplicates overlapping pages, and stops at 150', async () => { + let calls = 0 + const count = await countMergedAuthorPulls(pull, async (path, { body }) => { + calls++ + if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1)), true, 'next') + assert.equal(calls, 2) + assert.equal(body.variables.after, 'next') + return page(Array.from({ length: 100 }, (_, i) => entry(i + 100)), true, 'unused') + }) + assert.equal(count, 150) + assert.equal(calls, 2) +}) + +test('returns zero only for a complete empty history', async () => { + assert.equal(await countMergedAuthorPulls(pull, async () => page([])), 0) + for (const response of [{}, { errors: [{ message: 'rate limited' }], ...page([]) }, + page([null]), page([{ number: 1 }]), page([], true, null)]) { + await assert.rejects(countMergedAuthorPulls(pull, async () => response), /GitHub/u) + } + await assert.rejects(countMergedAuthorPulls(pull, async () => { throw new Error('offline') }), /offline/u) +}) + +test('rejects a repeated cursor instead of looping over a partial history', async () => { + await assert.rejects(countMergedAuthorPulls(pull, async () => page([], true, 'same')), /did not advance/u) +}) diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs index 1bfc50ff9f..36b7634d40 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -5,6 +5,7 @@ import process from 'node:process' import { pathToFileURL } from 'node:url' import { productionOwnership, LOGIN } from './blame-ownership.mjs' +import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs' const API_VERSION = '2026-03-10' const MAX_PULL_REQUEST_REVIEWS = 3_000 @@ -129,10 +130,10 @@ export async function listPullRequestReviews(api, repository, pullNumber) { /** * Evaluate approval points from current reviews and repository permissions. - * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, getOwnership?: typeof productionOwnership}} options Runtime inputs. - * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields. + * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. + * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; null author credit means history was not evaluated. */ -export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership }) { +export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) { const pull = pullRequestFromEvent(event) const policy = parseApprovalPolicy(policySource) if (pull.draft) { @@ -161,8 +162,14 @@ export async function evaluateApproval({ event, policySource, api, getOwnership }) } } - const unboostedPoints = approvals.reduce((sum, approval) => sum + approval.points, 0) - if (blockers.length === 0 && unboostedPoints < policy.requiredPoints + let authorCredit = null + const reviewerPoints = approvals.reduce((sum, approval) => sum + approval.points, 0) + if (blockers.length === 0 && reviewerPoints < policy.requiredPoints) { + const mergedCount = await getMergedCount(pull, api) + authorCredit = { mergedCount, points: authorCreditPoints(mergedCount) } + } + const pointsBeforeOwnership = reviewerPoints + (authorCredit?.points ?? 0) + if (blockers.length === 0 && pointsBeforeOwnership < policy.requiredPoints && approvals.some(approval => approval.points === policy.defaultPoints)) { const ownership = await getOwnership(pull, api) for (const approval of approvals) { @@ -180,12 +187,13 @@ export async function evaluateApproval({ event, policySource, api, getOwnership const next = total + approval.points if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points must be finite and at most Number.MAX_SAFE_INTEGER') return next - }, 0) + }, authorCredit?.points ?? 0) if (blockers.length > 0) { return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending', - `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`) + `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit) } - const state = points >= policy.requiredPoints ? 'success' : 'pending' + // Tolerate floating-point addition error without rounding approval scores. + const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending' return approvalResult( pull, policy.requiredPoints, @@ -193,25 +201,29 @@ export async function evaluateApproval({ event, policySource, api, getOwnership blockers, ignoredReviewers, state, - `${Number(points.toFixed(2))}/${policy.requiredPoints} approval points`, + `${Number(points.toFixed(3))}/${policy.requiredPoints} approval points${authorCredit ? ` (author ${authorCredit.points})` : ''}`, + authorCredit, ) } /** * Evaluate and publish the required commit status, publishing an error status when evaluation fails. - * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership}} options Runtime inputs. + * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. * @returns {Promise>>} Published approval decision. */ -export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, write = line => process.stdout.write(`${line}\n`) }) { +export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) { const pull = pullRequestFromEvent(event) await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl) let result try { - result = await evaluateApproval({ event, policySource, api, getOwnership }) + result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount }) } catch (error) { await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl) throw error } + write(result.authorCredit + ? `Author credit: ${result.authorCredit.points} (${result.authorCredit.mergedCount} merged PRs).` + : `Author credit: not evaluated (${pull.draft ? 'draft' : result.blockers.length ? 'blocking review' : 'reviewer points suffice'}).`) write(`Approval score: ${result.points}/${result.requiredPoints}.`) writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership }) => `@${login}: ${points}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`)) @@ -261,12 +273,13 @@ export async function approvalEventFromWorkflowRun({ event, api }) { return { ...event, pull_request: pull } } -function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) { +function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null) { return { pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha }, state, description: `${detail}.`, - points: approvals.reduce((total, approval) => total + approval.points, 0), + points: approvals.reduce((total, approval) => total + approval.points, authorCredit?.points ?? 0), + authorCredit, requiredPoints, approvals, blockers, @@ -312,12 +325,15 @@ function pullRequestFromEvent(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') const author = validateLogin(pull.user.login, 'pull-request author') + // REST node_id and GraphQL id identify the same global account node. + if (typeof pull.user.node_id !== 'string' || !pull.user.node_id) throw new Error('pull request has no author account ID') const headSha = validateHeadSha(pull.head.sha, 'pull request') return { repository, number: pull.number, draft: pull.draft, author, + authorId: pull.user.node_id, headSha, } } diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index c23e344fd0..95fffafab9 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -6,14 +6,16 @@ import { approvalEventFromWorkflowRun, createGitHubApi, effectiveReviewDecisions, - evaluateApproval, + evaluateApproval as evaluateWithHistory, listPullRequestReviews, parseApprovalPolicy, - runApprovalCheck, + runApprovalCheck as runWithHistory, publishApprovalPhase, } from './check-approval.mjs' const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') +const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options }) +const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options }) const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678' const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({ @@ -21,7 +23,7 @@ const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({ pull_request: { number: 42, draft, - user: { login: author }, + user: { login: author, node_id: 'author-id' }, head: { sha: HEAD_SHA }, }, }) @@ -313,7 +315,8 @@ test('publishes the required status and replaces stale success with error on eva }, }, }) - assert.equal(output[0], 'Approval score: 2/2.') + assert.equal(output[0], 'Author credit: not evaluated (reviewer points suffice).') + assert.equal(output[1], 'Approval score: 2/2.') const failures = [] await assert.rejects(runApprovalCheck({ @@ -375,7 +378,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999 : { permission: 'write' }, }) assert.equal(result.points, expectedPoints) - assert.equal(result.description, `${Number(expectedPoints.toFixed(2))}/2 approval points.`) + assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`) assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending') assert.equal(measurements, 1) assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines }) @@ -462,7 +465,7 @@ test('uses policy endpoints and formats only the displayed score', async () => { api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, }) assert.equal(result.points, 3.08) - assert.equal(result.description, '3.08/5 approval points.') + assert.equal(result.description, '3.08/5 approval points (author 0).') }) test('publishes setup phases without evaluating or installing dependencies', async () => { @@ -476,3 +479,141 @@ test('publishes setup phases without evaluating or installing dependencies', asy assert.deepEqual(states, ['pending', 'error']) await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u) }) + +for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4], [125, 0.5], [150, 0.6], [200, 0.6]]) { + test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => { + const result = await evaluateApproval({ + event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount, + api: async path => path.includes('/reviews?') ? [review('author', 'APPROVED')] : { permission: 'write' }, + }) + assert.equal(result.points, credit) + assert.equal(result.authorCredit.points, credit) + assert.equal(result.state, 'pending') + assert.deepEqual(result.approvals, []) + }) +} + +test('combines author credit and reviewer ownership at the passing threshold', async () => { + for (let mergedCount = 0; mergedCount <= 150; mergedCount++) { + const result = await evaluateApproval({ + event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount, + getOwnership: async () => ({ totalLines: 1000, reviewerLines: { writer: 250 - mergedCount } }), + api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, + }) + assert.equal(result.state, 'success', `author merged ${mergedCount}`) + } +}) + +test('blockers skip history and ignore non-write reviewers', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent(), policySource, getMergedCount: async () => { throw new Error('blockers must skip history') }, + getOwnership: async () => { throw new Error('blockers must skip blame') }, + api: async path => path.includes('/reviews?') + ? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')] + : { permission: path.includes('/reader/') ? 'read' : 'write' }, + }) + assert.equal(result.state, 'pending') + assert.equal(result.points, 1) + assert.equal(result.authorCredit, null) + assert.deepEqual(result.blockers, ['blocker']) + assert.deepEqual(result.ignoredReviewers, ['reader']) +}) + +test('drafts skip history and history failures revoke success with error', async () => { + await evaluateApproval({ + event: pullRequestEvent({ draft: true }), policySource, + getMergedCount: async () => { throw new Error('draft must skip history') }, + }) + const states = [] + await assert.rejects(runApprovalCheck({ + event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', + getMergedCount: async () => { throw new Error('history unavailable') }, + api: async (path, options) => { + if (path.includes('/reviews?')) return [] + states.push(options.body.state) + }, + }), /history unavailable/u) + assert.deepEqual(states, ['pending', 'error']) +}) + + +test('a score just below the threshold remains pending even if its display rounds to two', async () => { + const result = await evaluateApproval({ + event: pullRequestEvent(), policySource, getMergedCount: async () => 150, + getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 99999 } }), + api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, + }) + assert.equal(result.state, 'pending') + assert.ok(result.points < 2) +}) + +test('the publisher counts merged history through the production API path', async () => { + const event = pullRequestEvent() + const states = [] + const output = [] + const result = await runWithHistory({ + event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line), + getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }), + api: async (path, options) => { + if (path === '/graphql') { + assert.equal(options.body.variables.owner, 'deepseek-harness') + return { data: { repository: { pullRequests: { + nodes: Array.from({ length: options.body.variables.after ? 25 : 100 }, (_, i) => ({ + number: i + (options.body.variables.after ? 200 : 100), author: { id: event.pull_request.user.node_id }, + })), + pageInfo: { hasNextPage: !options.body.variables.after, endCursor: 'next' }, + } } } } + } + if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] + if (path.includes('/permission')) return { permission: 'write' } + states.push(options.body.state) + return {} + }, + }) + assert.equal(result.points, 2) + assert.equal(result.authorCredit.points, 0.5) + assert.equal(result.approvals[0].points, 1.5) + assert.deepEqual(states, ['pending', 'success']) + assert.equal(output[0], 'Author credit: 0.5 (125 merged PRs).') +}) + +test('sufficient reviewer points and drafts publish without querying author history', async () => { + for (const draft of [false, true]) { + const output = [] + const result = await runWithHistory({ + event: pullRequestEvent({ draft }), policySource, runUrl: 'https://github.example/run/1', + write: line => output.push(line), + api: async path => { + assert.notEqual(path, '/graphql') + if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] + if (path.includes('/permission')) return { permission: 'write' } + return {} + }, + }) + assert.equal(result.state, draft ? 'pending' : 'success') + assert.equal(result.authorCredit, null) + assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`) + } +}) + +test('rejects missing author node IDs before calling APIs even for drafts', async () => { + for (const nodeId of [undefined, '', 123]) { + const event = pullRequestEvent({ draft: true }) + event.pull_request.user.node_id = nodeId + await assert.rejects(evaluateWithHistory({ + event, policySource, api: async () => assert.fail('invalid events must not call GitHub'), + }), /account ID/u) + } +}) + +test('bot authors receive the same history credit', async () => { + const event = pullRequestEvent({ author: 'dependabot[bot]' }) + event.pull_request.user.type = 'Bot' + const result = await evaluateApproval({ + event, policySource, getMergedCount: async () => 150, + getOwnership: async () => ({ totalLines: 10, reviewerLines: { writer: 1 } }), + api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, + }) + assert.equal(result.authorCredit.points, 0.6) + assert.equal(result.state, 'success') +}) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 4c84abb328..1914398ea8 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/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 apps/cli/README.md -README.md: 92bd282b1148217a41ce12bbb4d227df088b13a0 -README.zh.md: b64ed8f81fb28acb70d96b1b0c116ea03dbd5fbb +README.md: 68500e54372d16a9ead8e548eed5a7e4836be3fb +README.zh.md: a1002812c3782f898d89793d89a50a1ab4ea4e1e diff --git a/apps/cli/README.md b/apps/cli/README.md index 92bd282b11..68500e5437 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -8,13 +8,13 @@ The `dsh` command is the sole supported Node application launcher: profiles are | Command | Purpose | |---|---| -| `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | +| `dsh ` / `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | | `dsh --profile --from-default-profile