diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml deleted file mode 100644 index 9714a56a7e..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.i18n.yaml +++ /dev/null @@ -1,19 +0,0 @@ -# Bilingual-pair consistency record for 2026-10-17-agent-controller-message-delivery.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller: - en: 9105f8148e107f43 - zh: a160e87bb0605da4 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/problem: - en: 5071232d9743096c - zh: b553a626538b3466 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/decision: - en: 1b61d351c4d2bdf1 - zh: d6f8180b30d97b34 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/alternatives-considered: - en: acc465cfe02b624c - zh: db1d06574f647130 -/agent-note-delivering-messages-from-outside-an-agent-through-the-agent-controller/consequences: - en: 9d8486586d044709 - zh: 24c7249421fd2541 diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md deleted file mode 100644 index 6d1c10ad9f..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Delivering messages from outside an Agent through the Agent controller - -Status: implemented - -English | [中文](2026-10-17-agent-controller-message-delivery.zh.md) - -## Problem - -Plugins need to decide, with Host context, whether a message someone sends may enter a Session now. The experimental forge panel must create a requested worktree before the first message reaches the Agent, and must stop a message after the Session's worktree was removed. The Host had no point for that decision. `session/prompt` appended a prompt to the Agent inbox synchronously, and the only seam every sender passes, `agent/pre-step`, runs after the inbox accepted the message and the client already showed it as sent. A pre-step rejection ends the turn without returning the message to its sender, and a queued message reaches pre-step only when its turn starts. A gate in the Web composer, tried first, had no Host context and was bypassed by every other client. - -## Decision - -The core package `dsh-agent-controller` owns how a message from outside an Agent enters its inbox. The base bundle mounts it as `ctx.agentController`, so every base-backed profile can deliver through it; `dsh-agent` and `agent-loop` keep their inbox methods unchanged. - -| Part | Contract | -|---|---| -| Entry | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` prepares the content, builds the message, asks the `agent-controller/admission` waterfall for an `AdmissionDecision`, checks the sender's cancellation and that the Agent is still registered, calls `beforeInsert`, then `followup()` or `steer()`, and resolves with the message id once the message is in the inbox | -| Sender work | `prepare` turns the sender's content into durable message content inside the Agent's delivery order; the optional `beforeInsert` runs synchronously immediately before the append, for bookkeeping that must precede the Agent's wake | -| Listeners | Run in registration order with Agent scope filtering and receive the Agent, the message, and a signal that aborts when the sender cancels or the controller stops; return the decision of `next()` to admit, await before `next()` to hold, and return `{ kind: 'reject', reason, message, presented }` to refuse without asking later listeners; throw only when the check itself fails | -| Delivery mode | Stays with the sender; listeners do not see it, because no admission policy depends on it | -| Order | Messages to one Agent are prepared, decided, and appended one at a time in arrival order; a sender that cancels before the append is not admitted and stops waiting at once, even behind a held message; disposing the controller stops every message that has not entered an inbox and finishes once those deliveries settled | -| Failures | A rejection raises `AgentAdmissionRejected`, a `HarnessError` with code `AGENT_ADMISSION_REJECTED`, with its reason and `presented`; senders recognize it by that code rather than its class and import only types from this package; a thrown listener error raises it with `agent-controller/admission-failed`, and an Agent that left the registry with `agent-controller/agent-gone`; errors from `prepare`, `beforeInsert`, `followup()`, and `steer()` pass through; no listener runs after the message entered the inbox | - -Only senders that deliver work on someone's behalf call `deliver()`. Messages the Agent's own machinery produces, such as job results, goal rounds, plan transitions, and question answers, keep `followup()`, `steer()`, and `inject()`: they belong to work that was already admitted. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook; it passes or refuses such a message. - -`presented` tells an interactive client that the refusing plugin explains the refusal in its own interface, so the client adds no generic notice. - -The Web Session Remote adopts the controller first. `session/prompt` resolves or resumes the Agent, acknowledges a repeated `requestId` itself, and delivers with a `prepare` that checks image input against the Session model, resolves upload receipts, and stores attachments, and a `beforeInsert` that binds the receipts to the request. It maps a refusal to `session/prompt-rejected` with `reason` and `presented`. The call stays open while a listener holds the message, and the client's cancellation stops it. The SDK server, ACP, headless runs, schedules, webhooks, and subagent prompts move to `deliver()` in later changes, each mapping a refusal to its own protocol. Until they do, a plugin that must cover them keeps an `agent/pre-step` check. - -Request deduplication stays in the Session Remote. It reads the Session history synchronously, and the [synchronous-read decision](2026-09-09-deprecate-synchronous-session-event-reads.md) prohibits copying such a read into new production code; the controller can take over deduplication once a projection records accepted request ids. - -The Web client keeps its existing echo: Enter clears the composer and shows the message at once, and a refusal reaches the client as an ordinary prompt failure, which retires the echo and restores the draft unless new text was typed meanwhile. - -## Alternatives considered - -- **An admission stage in `dsh-agent`.** `ctx.agents.admit()` with an `agent/admission` event reaches every sender as well, but it adds a delivery concern and a new extension point to the Agent core, whose contract the loop implements. A package composed in the base bundle reaches the same senders without changing that contract. -- **A Session Remote event, or a layer inside the Session Controller.** It fits the Web path, but the Session Controller is composed only in the Web profile, so ACP, SDK, and headless senders could never reach it. -- **`agent/pre-step`.** It covers every sender, but it runs after the inbox accepted the message and the client showed it as sent, a refusal drops the message instead of returning it, and a queued message is checked only when its turn starts. -- **Gated, asynchronous `followup()` and `steer()`.** Every internal sender would pass listeners meant for outside input, and their callers rely on synchronous enqueueing. -- **A composer gate in the Web client.** It has no Host context, and other clients and `ctx.conversation.send` bypass it. -- **Appending in the innermost step, with refusals thrown.** Listeners could act once `next()` returned with the message already in the inbox, but that capability, which no listener uses, cost three special cases: a listener failure after the append could only be logged, the inbox's own refusal had to be told apart from a listener failure, and a refusal thrown from another module copy of the error class needed a structural check. A returned decision keeps the stage a pure decision, as `agent/pre-step` is; a plugin that reacts to admitted messages listens to `agent/inbox/inserted`. - -The liveshare proposal in PR #3854 also introduced an admission hook, for caller identity. [Peer principals](2026-10-12-peer-principal-and-remote-access.md) replaced it; this stage decides whether the Agent can take a message now, independent of who sent it. - -## Consequences - -- A listener that holds one message keeps later messages to the same Agent waiting; other Agents proceed. -- While a listener holds a Web prompt, the client already shows its echo; a refusal then retires the echo and returns the draft. -- `prepare` stores attachments before the decision, so a refused prompt can leave a stored attachment unreferenced. -- Image prompts deliver inside the Session Remote's image admission chain, so a model switch waits while a listener holds an image prompt. -- Until every outside sender adopts `deliver()`, the stage is not a complete enforcement point. diff --git a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md b/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md deleted file mode 100644 index 5f5d247f7b..0000000000 --- a/.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note:经 Agent 控制器投递来自 Agent 外部的消息 - -Status: implemented - -[English](2026-10-17-agent-controller-message-delivery.md) | 中文 - -## 问题 - -插件需要借助 Host 侧的上下文,判断别人发来的一条消息现在能否进入 Session。实验性的 forge 面板必须在第一条消息到达 Agent 之前建好所请求的工作树,并在 Session 的工作树被删除后拦下消息。Host 此前没有做这个判断的位置:`session/prompt` 同步地把提示词追加进 Agent 收件箱,而所有发送方都会经过的唯一扩展点 `agent/pre-step` 运行在收件箱接受消息、客户端已显示为已发送之后。pre-step 拒绝会结束本轮,却不会把消息退回给发送方;排队的消息也要等到它的轮次开始才会到达 pre-step。最初尝试的 Web 输入框闸门拿不到 Host 上下文,其他客户端也都会绕过它。 - -## 决策 - -core 包 `dsh-agent-controller` 负责来自 Agent 外部的消息如何进入其收件箱。base 组合包把它挂载为 `ctx.agentController`,因此每个基于 base 的 profile 都能经它投递;`dsh-agent` 与 `agent-loop` 的收件箱方法保持不变。 - -| 部分 | 约定 | -|---|---| -| 入口 | `ctx.agentController.deliver(agent, { mode, source, signal, prepare, beforeInsert })` 准备内容、构造消息、向 `agent-controller/admission` waterfall 要一个 `AdmissionDecision`,检查发送方是否取消、Agent 是否仍在注册表中,调用 `beforeInsert`,再调用 `followup()` 或 `steer()`,消息进入收件箱后返回消息 id | -| 发送方的工作 | `prepare` 在该 Agent 的投递顺序内把发送方的内容转成持久的消息内容;可选的 `beforeInsert` 紧挨在放入之前同步运行,用于必须先于 Agent 唤醒完成的登记 | -| 监听器 | 按注册顺序运行,按 Agent 作用域过滤,拿到 Agent、消息和一个在发送方取消或控制器停止时中止的信号;返回 `next()` 的决定即放行,在 `next()` 前 await 即暂留,返回 `{ kind: 'reject', reason, message, presented }` 即拒绝且不再询问后面的监听器;只在检查本身执行失败时抛出异常 | -| 投递方式 | 留在发送方,监听器看不到,因为目前没有按投递方式区分的准入策略 | -| 顺序 | 同一个 Agent 的消息按到达顺序逐条准备、决定并放入;在放入之前取消的发送方不会被接纳,并立即结束等待,即使排在被暂留的消息之后;销毁控制器会停止所有尚未进入收件箱的消息,并在这些投递结束后才完成 | -| 失败 | 拒绝时抛出带其 reason 与 `presented` 的 `AgentAdmissionRejected`,它是 code 为 `AGENT_ADMISSION_REJECTED` 的 `HarnessError`;发送方按这个 code 而不是按类识别拒绝,对本包只导入类型;监听器抛出异常时 reason 为 `agent-controller/admission-failed`,Agent 离开注册表时为 `agent-controller/agent-gone`;`prepare`、`beforeInsert`、`followup()` 与 `steer()` 的错误原样抛出;消息进入收件箱之后不再有监听器运行 | - -只有代表他人送来工作的发送方调用 `deliver()`。Agent 自身机制产生的消息,例如后台任务结果、goal 轮次、plan 切换和问题答复,继续使用 `followup()`、`steer()` 与 `inject()`:它们属于已经接纳的工作。消息来源无人值守时(例如定时任务或 webhook),监听器不得等待人来处理,只能放行或拒绝。 - -`presented` 告诉交互式客户端,拒绝它的插件会在自己的界面里给出说明,客户端因此不再追加通用提示。 - -Web Session Remote 首先接入控制器。`session/prompt` 自己解析或恢复 Agent、确认重复的 `requestId`,再调用投递:`prepare` 按 Session 模型检查图片输入、解析上传凭证并存储附件,`beforeInsert` 把凭证绑定到该请求。它把拒绝映射为带 `reason` 与 `presented` 的 `session/prompt-rejected`。监听器暂留消息期间调用保持未完成,客户端取消会让它停止。SDK 服务端、ACP、headless 运行、定时任务、webhook 与子代理提示词在后续改动中改用 `deliver()`,各自把拒绝映射到自己的协议。在此之前,必须覆盖它们的插件仍保留一个 `agent/pre-step` 检查。 - -请求去重留在 Session Remote。它同步读取 Session 历史,而[同步读取决策](2026-09-09-deprecate-synchronous-session-event-reads.zh.md)禁止把这类读取复制到新的生产代码;等有投影记录已接收的请求 id 后,控制器才能接管去重。 - -Web 客户端保留现有的回显方式:Enter 立即清空 composer 并显示消息;拒绝在客户端表现为一次普通的提示词失败,回显撤下,草稿回到 composer,除非期间已经输入了新内容。 - -## 备选方案 - -- **在 `dsh-agent` 里加接纳阶段。** `ctx.agents.admit()` 加 `agent/admission` 事件同样能覆盖所有发送方,但它把投递职责和一个新扩展点加进了 Agent 核心,而 Agent 的约定由循环实现。组合在 base 组合包里的独立包能覆盖同样的发送方,又不改变这项约定。 -- **Session Remote 自己的事件,或 Session Controller 内部的一层。** 它适合 Web 路径,但 Session Controller 只在 Web profile 中组合,ACP、SDK 与 headless 发送方永远无法使用它。 -- **`agent/pre-step`。** 它覆盖所有发送方,但运行在收件箱接受消息、客户端显示为已发送之后;拒绝会丢掉消息而不是退回;排队的消息要到轮次开始才会被检查。 -- **让 `followup()` 与 `steer()` 异步并经过闸门。** 每个内部发送方都会经过为外部输入准备的监听器,而它们的调用方依赖同步入队。 -- **Web 客户端的输入框闸门。** 它拿不到 Host 上下文,其他客户端和 `ctx.conversation.send` 都会绕过它。 -- **在最内层步骤放入消息,并以抛出异常表示拒绝。** 监听器能在 `next()` 返回、消息已进入收件箱之后继续执行,但这项没有监听器使用的能力带来三处特殊处理:放入之后的监听器失败只能记录日志;要把收件箱自身的拒绝与监听器失败区分开;另一个模块副本抛出的错误类需要按结构识别。返回决定让这个阶段和 `agent/pre-step` 一样只做决定;需要对已接纳消息作出反应的插件监听 `agent/inbox/inserted`。 - -PR #3854 中的 liveshare 方案也引入过一个接纳钩子,用于调用方身份。[Peer principals](2026-10-12-peer-principal-and-remote-access.zh.md) 取代了它;本阶段判断 Agent 现在能否接收一条消息,与发送者是谁无关。 - -## 影响 - -- 监听器暂留一条消息时,同一 Agent 之后的消息都要等待;其他 Agent 不受影响。 -- 监听器暂留一条 Web 提示词期间,客户端已经显示其回显;随后被拒绝时,回显撤下,草稿回到 composer。 -- `prepare` 在决定之前存储附件,因此被拒绝的提示词可能留下一个无人引用的已存附件。 -- 图片提示词在 Session Remote 的图片接纳链内投递,因此监听器暂留图片提示词期间,切换模型要等待。 -- 在所有外部发送方都改用 `deliver()` 之前,这个阶段还不是完整的强制执行点。 diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml new file mode 100644 index 0000000000..3e9350f2b3 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-10-19-pi-ai-system-reminder-fallback.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md +/agent-note-pi-ai-system-reminder-fallback: + en: f43b5c6974289180 + zh: abc0a86b1d137189 +/agent-note-pi-ai-system-reminder-fallback/problem: + en: 5247278483ca4e7a + zh: ca711671a43fae76 +/agent-note-pi-ai-system-reminder-fallback/decision: + en: 604eb72704c73328 + zh: 6661d1065f65ecf9 +/agent-note-pi-ai-system-reminder-fallback/alternatives-considered: + en: 0e2aaf897587ebb0 + zh: 0a7f9a9cb7ddffaf +/agent-note-pi-ai-system-reminder-fallback/consequences: + en: 8a0c53289a04c64a + zh: 591e602eb2036a1a diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md new file mode 100644 index 0000000000..458fdb83ca --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai system reminder fallback + +Status: implemented + +English | [中文](2026-10-19-pi-ai-system-reminder-fallback.zh.md) + +## Problem + +Pi-ai concatenates later system messages into the initial prompt for models without native mid-conversation system support. Later system messages supplied to the adapter therefore invalidate the preceding conversation cache prefix. + +## Decision + +The pi-ai adapter uses the prepared model's existing `supportsMidConvoSystemMessages` capability mapping. It preserves native system messages on capable models and converts each non-empty later system message into a user message wrapped in `` otherwise, before pi-ai receives the context. Leading system text remains in the initial prompt. Message order, repeated updates, and existing text-block concatenation remain intact. Durable messages retain their original roles. + +The fallback belongs to the adapter because it depends on the selected protocol capability. Prompt admission remains owned by the agent loop, which consolidates system-prompt updates on routes without native support; this fallback only handles later system messages present in the supplied request. + +## Alternatives considered + +**Let pi-ai concatenate updates.** Retains system-role priority but changes the initial prompt whenever later system text changes, defeating prefix reuse. + +**Advertise native support or send developer messages.** Misstates model capability or relies on another unsupported instruction role. The fallback uses ordinary user messages and does not change capability metadata. + +## Consequences + +The wrapper does not confer system-role authority, so reminders have user-role priority. Cache hits remain provider-dependent; tool changes and compaction can still invalidate prefixes. Both text-only and attachment-aware conversion apply the same fallback, and wire tests inspect the real pi-ai serializer output. diff --git a/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md new file mode 100644 index 0000000000..02e9ca648f --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-10-19-pi-ai-system-reminder-fallback.zh.md @@ -0,0 +1,25 @@ +# Agent Note: Pi-ai 系统提醒回退 + +Status: implemented + +[English](2026-10-19-pi-ai-system-reminder-fallback.md) | 中文 + +## 问题 + +对于不原生支持对话中系统消息的模型,pi-ai 会把后续系统消息拼接进初始提示词。因此,传给适配器的后续系统消息会使此前的对话缓存前缀失效。 + +## 决策 + +pi-ai 适配器使用已准备模型的现有 `supportsMidConvoSystemMessages` 能力映射。对于具备该能力的模型,适配器保留原生系统消息;否则,在 pi-ai 接收上下文之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。开头的系统文本仍位于初始提示词中。消息顺序、重复更新和现有文本块拼接方式均保持不变。持久消息保留原始角色。 + +回退由适配器负责,因为它取决于所选协议的能力。提示词纳入仍由 Agent loop 负责,它在不支持原生更新的路由上合并系统提示词更新;此回退仅处理传入请求中已有的后续系统消息。 + +## 考虑过的替代方案 + +**让 pi-ai 拼接更新。** 保留系统角色优先级,但后续系统文本每次变化都会改变初始提示词,破坏前缀复用。 + +**宣称原生支持或发送 developer 消息。** 错报模型能力,或依赖另一种不受支持的指令角色。回退使用普通用户消息,不改变能力元数据。 + +## 后果 + +包裹文本不会赋予系统角色权限,因此提醒具有用户角色优先级。缓存命中仍取决于提供方;工具变化和压缩仍可能使前缀失效。纯文本与附件感知转换使用相同的回退,线协议测试检查真实 pi-ai 序列化器的输出。 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml index 7a93249b1a..f50ce14bc9 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.i18n.yaml @@ -15,8 +15,8 @@ en: db0a2deb9193a00d zh: dab5a65b88957464 /agent-note-experimental-auto-review-before-each-tool-call/decision/one-complete-reviewer-request: - en: 3a07a59b6c5ec26c - zh: e4f3290dc6d26368 + en: 7bd9fef90e8639b9 + zh: e2a096885c9c1e83 /agent-note-experimental-auto-review-before-each-tool-call/decision/result-and-cancellation: en: 647fe68c52b907e0 zh: 267a739bad44ad65 diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.md index 66981180c1..9434c92f41 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.md @@ -30,19 +30,20 @@ The reviewer derives authority from existing Session facts. Text with `source.ki ### One complete reviewer request -The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request has five fixed partitions: +The integration uses only the latest `request/header.config` provider/model and the shipped adapter's default reasoning. It neither compares redundant route metadata nor copies the main agent's request. The request is a fixed system prompt followed by user messages: -| Partition | Retained input | +| Part | Retained input | | --- | --- | -| `REVIEW_POLICY` | Fixed classification, source authority, and strict result rules; allow executes immediately with Full access and no later confirmation | -| `ENVIRONMENT` | Existing Session header `cwd` only | -| `PROJECT_INSTRUCTIONS` | Visible project instructions with their original sources and constraint role | -| `FILTERED_HISTORY` | Current compaction surface's sourced user/direct-parent messages, checkpoints, image/attachment facts, and historical call names with logged arguments | -| `PENDING_ACTION` | Tool name, description, parameter schema, and parsed arguments | +| System prompt `REVIEW_POLICY` | Fixed classification, source authority, message layout, and strict result rules; allow executes immediately with Full access and no later confirmation | +| First user message `ENVIRONMENT` | Existing Session header `cwd` only | +| One user message per retained item | Current compaction surface's sourced user/direct-parent messages, project instructions with the constraint role, checkpoints, image/attachment facts, historical call names with logged arguments, and a `tool-schema` item before the first call that used each reviewed schema, in surface order | +| Final user message | The pending call with its logged arguments; the latest earlier `tool-schema` item with its mode and name gives the tool description and parameter schema | -The reviewer builds those two action sections from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver. +Each item is its own message and keeps the same text in later reviews: the pending call becomes a historical call, and the integration remembers, for each live Session, the schema each reviewed call showed so that it reappears at the same position. A review's messages therefore begin with all messages of the previous review. Providers that cache only at message ends, such as OpenAI implicit breakpoints on GPT-5.6 and later, or at a final marker with lookback, such as Anthropic `cache_control`, reuse the previous review; one growing user message let them reuse only the policy. The schema memory is process-local, so the first review after a restart omits earlier schemas and misses the cache once. -The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only in `PENDING_ACTION`; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget. +The reviewer builds the history and the pending call from the Session's complete action history: an authorization is the earlier call the `REVIEW_POLICY` source rules make eligible, and a duplicate or conflicting identity has to be visible anywhere in the log rather than inside a recent window, so neither the Session projections nor a bounded read serves the decision. The read is the deprecated synchronous `snapshotEvents()` under a line-scoped `typescript/no-deprecated` waiver. + +The main agent's V3 `system/message` nodes, assistant text/reasoning, and tool results are excluded. The current call must belong to the open step recorded by `step/start`; missing step ownership fails closed. It appears only as the final message; an unstarted sibling has no historical call fact. Native schema comes from the latest request header. PTC captures a frozen schema at binding construction and passes it through the scheduler into `ToolExecution`; descriptions and parameter schemas never enter start/settle events or the Session/SDK wire. Missing, inconsistent, or ambiguous action facts reject the call without consulting the live registry. An oversized request fails closed without summarization, truncation, another compaction pass, or a small output-token budget. ### Result and cancellation diff --git a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md index a585142928..5ba7a8b39c 100644 --- a/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md +++ b/.agents/notes/implemented/feature/2026-08-28-auto-review.zh.md @@ -30,19 +30,20 @@ Reviewer 从既有 Session 事实派生权威。`source.kind === 'user'` 的文 ### 一次完整 reviewer 请求 -Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求固定包含五个分区: +Integration 只使用最新 `request/header.config` 的 provider/model 与 shipped adapter 默认 reasoning。它既不比较冗余 route metadata,也不复制主 agent 请求。请求由固定 system prompt 和其后的 user 消息组成: -| 分区 | 保留输入 | +| 部分 | 保留输入 | | --- | --- | -| `REVIEW_POLICY` | 固定分类、来源权威与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 | -| `ENVIRONMENT` | 仅既有 Session header `cwd` | -| `PROJECT_INSTRUCTIONS` | 可见项目指令,保留原始来源并赋予约束角色 | -| `FILTERED_HISTORY` | 当前 compaction surface 中带来源的用户/直接父级消息、checkpoint、图片/附件事实,以及历史调用名称与日志参数 | -| `PENDING_ACTION` | 工具名称、描述、参数 schema 与解析后的 arguments | +| System prompt `REVIEW_POLICY` | 固定分类、来源权威、消息布局与严格结果规则;allow 后立即以 Full access 执行,没有后续确认 | +| 第一条 user 消息 `ENVIRONMENT` | 仅既有 Session header `cwd` | +| 每个保留条目一条 user 消息 | 当前 compaction surface 中带来源的用户/直接父级消息、带约束角色的项目指令、checkpoint、图片/附件事实、历史调用名称与日志参数,以及在首次使用某个已审 schema 的调用之前出现的 `tool-schema` 条目,按 surface 顺序排列 | +| 最后一条 user 消息 | 待审调用及其日志参数;其之前同 mode 与名称的最新 `tool-schema` 条目给出工具描述与参数 schema | -reviewer 从 Session 的完整动作历史构建这两个动作分节:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。 +每个条目各占一条消息,并在后续评审中保持相同文本:待审调用会变成历史调用,integration 为每个存活 Session 记住每次已审调用展示的 schema,使其在同一位置再次出现。因此一次评审的消息以上一次评审的全部消息开头。只在消息结尾缓存的 provider(如 GPT-5.6 及以后的 OpenAI 隐式断点),或在末尾标记处向前回溯的 provider(如 Anthropic `cache_control`),都能复用上一次评审;单条不断增长的 user 消息只能让它们复用策略。Schema 记忆只在进程内,因此重启后的第一次评审会缺少先前的 schema,并有一次缓存未命中。 -主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只在 `PENDING_ACTION` 出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。 +reviewer 从 Session 的完整动作历史构建历史与待审调用:授权是符合 `REVIEW_POLICY` 来源规则的那个更早调用,而重复或冲突的身份必须在整份日志的任何位置都可见,而不是只在近期窗口内可见,因此 Session 投影与有界读取都无法支撑该判定。这次读取使用已废弃的同步 `snapshotEvents()`,并带有行级 `typescript/no-deprecated` 豁免。 + +主 agent 的 V3 `system/message` 节点、assistant 正文/reasoning 与 tool results 全部排除。当前调用必须属于 `step/start` 记录的开放 step;缺少 step 归属时拒绝执行。该调用只作为最后一条消息出现;尚未开始的 sibling 没有历史调用事实。原生 schema 来自最新 request header。PTC 在 binding 构造时捕获冻结 schema,经由调度器传入 `ToolExecution`;描述与参数 schema 不进入开始/结算事件或 Session/SDK wire。动作事实缺失、不一致或有歧义时拒绝调用,不查询 live registry。超窗请求直接拒绝,不做摘要、截断、额外 compaction 或设置小型输出 token 预算。 ### 结果与取消 diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml deleted file mode 100644 index 9bf8d4085d..0000000000 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.i18n.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# Bilingual-pair consistency record for 2026-09-29-weighted-approval-decision-check-run.md (docs/i18n/README.md): per heading -# section, a hash of its English and Chinese blocks outside code blocks and generated regions. -# After editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md -/agent-note-weighted-approval-decisions-are-completed-check-runs: - en: 1b120e96b0f5f340 - zh: ff20c20fe0558964 -/agent-note-weighted-approval-decisions-are-completed-check-runs/problem: - en: 6ba6f6a15a1562ec - zh: 1cbef3e736c258e6 -/agent-note-weighted-approval-decisions-are-completed-check-runs/decision: - en: 7dcebfed221053cc - zh: 84346547d1b95ee3 -/agent-note-weighted-approval-decisions-are-completed-check-runs/verification: - en: 3771d95af79c69bb - zh: d4053338d878511f -/agent-note-weighted-approval-decisions-are-completed-check-runs/alternatives-considered: - en: b8daea66b0a90d26 - zh: fb7079c815b38281 -/agent-note-weighted-approval-decisions-are-completed-check-runs/consequences: - en: 5708f2112ee1e647 - zh: 02d29ce5b18db06d diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md deleted file mode 100644 index ed9281a56d..0000000000 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: Weighted approval decisions are completed check runs - -Status: implemented - -English | [中文](2026-09-29-weighted-approval-decision-check-run.zh.md) - -## Problem - -The weighted approval policy must block merging until its conditions hold, distinguish an unmet condition from a failed evaluation, and let tools that wait for pull-request checks to finish terminate. A draft pull request, insufficient approval points, and an effective `CHANGES_REQUESTED` review from a write-capable reviewer are reversible review states rather than evaluation failures. - -Commit statuses offer only `pending`, `success`, `failure`, and `error`. A `pending` commit status blocks merging, but `gh pr checks --watch` and other CI watchers treat it as running work and wait until someone approves. A `failure` commit status ends the wait but reports an unmet, reversible condition as a malfunction and conflates the policy outcome with publisher health. - -## Decision - -The [publisher](../../../../.github/review-ownership/check-approval.mjs) reports the decision as the `weighted approval decision` check run on the pull-request head, written with the job's GitHub Actions token. Branch rules require that check run with GitHub Actions as its expected source. - -| Condition | Check run | -|---|---| -| First evaluation of a head | `in_progress` until the decision is written | -| Re-evaluating a head that published `success` | conclusion `action_required` until the decision is written | -| Draft, fewer than the required points, or an effective write-capable `CHANGES_REQUESTED` review | `completed`, conclusion `action_required` | -| Ready, threshold met, and no blocking review | `completed`, conclusion `success` | -| Setup, evaluation, or cancellation failure | `completed`, conclusion `failure` | - -A check run that reached `completed` never returns to `in_progress` or `queued`: GitHub answers `200` to that update and leaves the run completed, so only the conclusion is mutable. The publisher therefore revokes a published success by writing the blocking conclusion before dependency setup, which covers the setup and history fetch that dominate a cold evaluation. The head keeps one GitHub Actions run with the required name: the publisher updates the head's existing run and creates one only when none exists. An incomplete list or a run without a valid ID fails the publication. The publish step records the conclusion it wrote in a step output, and the setup-failure step runs only when no conclusion was recorded: a published decision survives a later failure or cancellation, while a run cancelled before that write concludes `failure`. A run cancelled before the publish step records its conclusion, including a cancellation whose file-command outputs the runner drops, therefore concludes `failure`, which still blocks the merge. During a re-evaluation, a previous decision other than a published success keeps its conclusion and title until the new decision is written; a published success is replaced by `action_required` and the preparation title. - -A blocking review dominates the point total, so the conclusion remains `action_required` even when counted approvals reach the threshold. GitHub accepts only `success`, `neutral`, and `skipped` for a required check, so `action_required` blocks merging while completing the check. - -## Verification - -[Approval policy tests](../../../../.github/review-ownership/check-approval.test.mjs) pin the threshold-reaching blocker case, the exact published check-run payloads, revoking a published success through its conclusion, updating an existing run instead of creating another, recording the conclusion that the setup-failure step reads, and invalid list responses. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the `checks: write` permission, the `revoke` and `failure` phase commands, the publish step identity, and the fallback that runs when a run fails or is cancelled before publishing. - -The transition matrix and the fallback condition were measured on 2026-09-30 with throwaway tag runs in this repository, using the same GitHub Actions token as the publisher. A run created as `in_progress` and completed as `success` accepted `status: in_progress` and `status: queued` with `200` and stayed `completed`, while conclusion-only updates moved it to `action_required`, back to `success`, and to `failure`. Those runs measured the fallback condition as it stood on 2026-09-30 (`failure() || (cancelled() && steps.publish.outcome != 'success')`): the step ran for a failed publish and for a cancellation during the publish step, and did not run when the job was cancelled after the decision was published. The current condition also requires an empty `concluded` output, so a job cancelled after the publish step recorded its conclusion skips the fallback in either condition. A `timeout-minutes` cancellation was measured separately on 2026-10-20: a job that exceeded its one-minute timeout on a throwaway `push` branch still ran its `cancelled()` step, so the step executes on that path; whether a cancelled step's file-command outputs are exposed was not measured, and dropping them only makes that cancellation conclude `failure`. - -The list response's `total_count` counts the `check_name`-filtered runs rather than every check on the head: on 2026-10-20 a head carrying 33 check runs reported `total_count` 0 for a name that matches none and 2 for an existing name, so an equality between `total_count` and the returned array length rejects a truncated page. - -## Alternatives considered - -**Keep the `pending` commit status.** Branch rules block correctly, but CI watchers cannot distinguish an approval wait from running checks and do not finish. - -**Publish `failure` for unmet conditions.** This ends the wait, but it represents an unmet, reversible merge condition as a malfunction and conflates the policy outcome with publisher health. - -**Revoke by returning the completed run to `in_progress`.** GitHub accepts that update without applying it, so the published success would stand through dependency setup and the history fetch. - -**Publish nothing until success.** A required check that was never reported remains expected and is invisible to many watchers. Revoking a success already published on the same head still requires a non-success write, and each watcher's treatment of a missing required check is unspecified. - -**Keep the `weighted approval` name.** When a check and a commit status share a required name, GitHub requires both to pass. Heads that carry a `pending` commit status from the earlier publisher would stay blocked until a new push. - -**Create a new check run on every evaluation.** Several same-name check runs on one head make the result depend on how GitHub and each watcher select among them. - -**Let approval points override a blocking review.** This makes the score the only success condition, but it permits success while a write-capable reviewer's effective decision still requests changes. - -## Consequences - -CI watchers finish while the decision waits on reviewers. `gh pr checks` groups `action_required` with failed checks and exits non-zero; consumers distinguish it by the check name, the `ACTION_REQUIRED` conclusion, or the output title, which carries the score or blocker. The `weighted approval publisher` Actions job and the `failure` conclusion carry operational failures. - -Re-evaluating a head that already published `success` reports `action_required` until the new decision is written, so a watcher that polls inside that window sees a failed check. - -The branch-rule switch leaves two gaps on heads the publisher has not evaluated since it. A check run's name does not identify its publisher. Any workflow in the pushed branch can name a job `weighted approval decision`: on 2026-10-20 a job with `if: false` on a throwaway branch produced a `github-actions` check run with that name and conclusion `skipped` on the pushed commit, and a required check accepts `skipped`. The publisher therefore revokes every passing conclusion it finds before dependency setup and adopts every same-named run when it publishes, which covers a run that exists at either write; a same-named passing run created after the publisher's last write on the head is outside its control. Requiring the check from a dedicated GitHub App is the only complete binding, and it replaces the job token with an app-installed identity. - -A head without the decision check run reports the required check as expected until the next publisher event creates it; re-running a pre-switch run does not, because a re-run replays that run's earlier workflow file, which calls the removed `pending` phase against the new script. A re-run of a pre-switch run on 2026-10-20 executed the pre-switch step names and no check-run write. A pull-request edit triggers the publisher's `edited` event, so the rollout can create the run on a head that has none. A head that also carries the earlier `weighted approval` commit status keeps that pending status until the same event: the combined status resolves each context to its latest status, so the rollout posts a terminal `success` status for that context on every open head instead of waiting for its next push. diff --git a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md b/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md deleted file mode 100644 index b221cc6cf2..0000000000 --- a/.agents/notes/implemented/process/2026-09-29-weighted-approval-decision-check-run.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: 加权批准决策使用已完成的 check run - -Status: implemented - -[English](2026-09-29-weighted-approval-decision-check-run.md) | 中文 - -## 问题 - -加权批准策略必须在条件满足之前阻止合并,区分尚未满足的条件与失败的评估,并让等待 PR 检查结束的工具能够退出。draft PR、批准点数不足,以及具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审,都是可撤销的评审状态,而不是评估故障。 - -commit status 只提供 `pending`、`success`、`failure` 和 `error`。`pending` commit status 会阻止合并,但 `gh pr checks --watch` 和其他 CI 监视工具把它当作仍在运行的工作,会一直等到有人批准。`failure` commit status 能结束等待,但会把尚未满足且可撤销的条件报告为故障,并混淆策略结果与 publisher 的健康状态。 - -## 决策 - -[publisher](../../../../.github/review-ownership/check-approval.mjs) 使用该 job 的 GitHub Actions token,在 PR head 上以 `weighted approval decision` check run 报告决策。分支规则要求该 check run,并把 GitHub Actions 设为预期来源。 - -| 条件 | Check run | -|---|---| -| head 的首次评估 | 写入决策前为 `in_progress` | -| 重新评估已发布 `success` 的 head | 写入决策前 conclusion 为 `action_required` | -| draft、点数少于要求,或存在具有写权限的评审人仍然生效的 `CHANGES_REQUESTED` 评审 | `completed`,conclusion 为 `action_required` | -| 已 ready、达到阈值且没有阻塞性评审 | `completed`,conclusion 为 `success` | -| 安装、评估或取消导致的失败 | `completed`,conclusion 为 `failure` | - -已经到达 `completed` 的 check run 不会再回到 `in_progress` 或 `queued`:GitHub 对该更新返回 `200`,但让 run 保持已完成,因此只有 conclusion 可变。publisher 因此在依赖安装前用阻塞性 conclusion 撤销已发布的成功,覆盖冷启动中占主要耗时的安装与历史拉取。同一 head 上只保留一个同名 GitHub Actions run:publisher 更新 head 上已有的 run,仅在不存在时创建一个。列表不完整或 run 没有有效 ID 时发布失败。publish 步骤把自己写入的 conclusion 记录到 step output,安装失败步骤只在该 output 未记录时运行:已发布的决策在之后的失败或取消中保留,而在该写入之前被取消的 run 以 `failure` 结束。在 publish 步骤记录 conclusion 之前被取消的 run——包括 runner 丢弃其 file-command 输出的取消——因此以 `failure` 结束,仍然阻止合并。重新评估期间,先前决策若不是已发布的 `success`,其 conclusion 与标题会一直显示到新决策写入为止;已发布的 `success` 会被替换为 `action_required` 与准备中的标题。 - -阻塞性评审的优先级高于点数总和,因此即使计入的批准已经达到阈值,conclusion 仍为 `action_required`。GitHub 只接受 `success`、`neutral` 和 `skipped` 作为必需检查的通过结果,因此 `action_required` 会阻止合并,同时让该检查处于完成状态。 - -## 验证 - -[批准策略测试](../../../../.github/review-ownership/check-approval.test.mjs)锁定已达到阈值但仍有 blocker 的场景、准确发布的 check run payload、通过 conclusion 撤销已发布的成功、更新已有 run 而不是另建一个、记录安装失败步骤所读的 conclusion,以及无效的列表响应。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定 `checks: write` 权限、`revoke` 和 `failure` 阶段命令、publish 步骤的身份,以及运行失败或在发布前被取消时执行的兜底步骤。 - -转换矩阵与该兜底条件于 2026-09-30 在本仓库用一次性 tag 运行实测,使用的是与 publisher 相同的 GitHub Actions token。以 `in_progress` 创建、以 `success` 完成的 run,接受 `status: in_progress` 和 `status: queued` 的更新时都返回 `200` 并保持 `completed`;只改 conclusion 的更新把它依次改为 `action_required`、`success` 和 `failure`。这些运行测的是 2026-09-30 当时的兜底条件(`failure() || (cancelled() && steps.publish.outcome != 'success')`):兜底步骤在发布失败以及发布步骤执行期间被取消时运行,在决策发布之后才取消 job 时不运行。当前条件还要求 `concluded` output 为空,因此两种条件下,publish 步骤已记录结论后才取消的 job 都跳过兜底。`timeout-minutes` 取消路径于 2026-10-20 单独实测:一个在一次性 `push` 分支上超过一分钟超时的 job 仍然执行了 `cancelled()` 步骤,因此该步骤在这条路径上会执行;取消步骤的 file-command 输出是否会被暴露未实测,若被丢弃只会让该次取消以 `failure` 结束。 - -列表响应中的 `total_count` 统计的是经 `check_name` 过滤后的 run,而不是该 head 上的全部检查:2026-10-20 实测,一个带有 33 个 check run 的 head,对不匹配任何 run 的名称返回 `total_count` 0,对已存在的名称返回 2,因此用 `total_count` 与返回数组长度相等来拒绝被截断的分页是有效的。 - -## 考虑过的替代方案 - -**保留 `pending` commit status。** 分支规则能正确阻止合并,但 CI 监视工具无法区分等待批准与仍在运行的检查,因此不会结束。 - -**为尚未满足的条件发布 `failure`。** 该方案能结束等待,但会把尚未满足且可撤销的合并条件表示为故障,并混淆策略结果与 publisher 的健康状态。 - -**通过把已完成的 run 改回 `in_progress` 来撤销。** GitHub 接受该更新但不执行,因此已发布的成功会在依赖安装与历史拉取期间继续存在。 - -**在成功之前不发布任何结果。** 从未上报的必需检查保持 expected 状态,许多监视工具看不到它。撤销同一 head 上已发布的成功仍然需要写入非成功结果,而各监视工具如何对待缺失的必需检查没有规定。 - -**保留 `weighted approval` 名称。** 当 check 和 commit status 使用同一个必需名称时,GitHub 要求两者都通过。带有旧 publisher 所写 `pending` commit status 的 head 会一直被阻止,直到出现新的 push。 - -**每次评估都创建新的 check run。** 同一 head 上存在多个同名 check run 时,结果取决于 GitHub 和各监视工具如何在其中选择。 - -**允许批准点数覆盖阻塞性评审。** 该方案会让分数成为唯一的成功条件,但也允许在具有写权限的评审人仍然有效地要求修改时发布成功结果。 - -## 后果 - -决策等待评审人时,CI 监视工具会结束。`gh pr checks` 把 `action_required` 与失败的检查归为一组并以非零状态退出;消费方可以通过 check 名称、`ACTION_REQUIRED` conclusion 或携带分数或 blocker 的输出标题加以区分。`weighted approval publisher` Actions job 和 `failure` conclusion 承载运行故障信号。 - -重新评估已经发布 `success` 的 head 时,在新决策写入前会报告 `action_required`,因此在该窗口内轮询的监视工具会看到失败的检查。 - -分支规则切换后,对切换以来 publisher 尚未评估过的 head 存在两个缺口。check run 的名称并不标识发布者。被推送分支里的任何 workflow 都可以把一个 job 命名为 `weighted approval decision`:2026-10-20 在一次性分支上,带 `if: false` 的 job 在被推送的 commit 上产生了 `github-actions` 来源、该名称、conclusion 为 `skipped` 的 check run,而必需检查接受 `skipped`。因此 publisher 在依赖安装前撤销它找到的所有可通过的 conclusion,并在发布时接管所有同名 run,覆盖两次写入时已存在的 run;在 publisher 对该 head 最后一次写入之后新出现的同名可通过 run 不在其控制范围内。把该检查绑定到专用 GitHub App 是唯一完整的绑定方式,它会用 App 安装身份替换 job token。 - -没有该决策 check run 的 head 会把必需检查报告为 expected,直到下一次 publisher 事件创建它;重跑切换前的 run 不能完成迁移,因为重跑会回放该 run 当时的 workflow 文件,而它调用的 `pending` 阶段已被新脚本拒绝。2026-10-20 对切换前 run 的重跑执行的是切换前的步骤名,没有写入 check run。编辑 PR 会触发 publisher 的 `edited` 事件,因此 rollout 可以对没有该 run 的 head 制造这次事件。同时也带有先前 `weighted approval` commit status 的 head 会一直保留那个 pending status 直到同一事件:combined status 对每个 context 取最新的 status,因此 rollout 对每个开放 head 发布该 context 的终态 `success` status,而不是等它下一次 push。 diff --git a/.agents/skills/dsh-client-ui-ux/SKILL.md b/.agents/skills/dsh-client-ui-ux/SKILL.md index 4fbfa1a876..6d293a5f86 100644 --- a/.agents/skills/dsh-client-ui-ux/SKILL.md +++ b/.agents/skills/dsh-client-ui-ux/SKILL.md @@ -56,7 +56,7 @@ Scrollbars stay inside their container: use the shared scrollbar styles rather t ## Responsive layouts -Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, text truncation, title wrapping, icon-only controls, and focus during width changes. +Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, and focus during width changes. ## Platform window adaptation diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 9b0b48bc57..e5cf8c079f 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -23,6 +23,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo — - [dsh-ci-test-reliability](../dsh-ci-test-reliability/SKILL.md): isolation and regression-proof rules for resource-owning, asynchronous, or flaky tests and fixtures. - [dsh-client-ui-ux](../dsh-client-ui-ux/SKILL.md): feedback-surface choice, overlay safety, platform window adaptation, and visual token discipline for product-user-visible GUI changes. - [dsh-error-handling](../dsh-error-handling/SKILL.md): failure classification, handling ownership, containment, state guarantees, and actionable reports. +- [dsh-session-projection](../dsh-session-projection/SKILL.md): projection ownership, state size, historical reads, and migrations. - [docs/testing.md](../../../docs/testing.md) and the [quality-gates reference](../../../docs/testing.md): required test tiers and gates. - [Agent Notes](../../notes/README.md): design rationale. Treat disagreement with an Agent Note as a design discussion, not an automatic veto. - For bilingual changes, read [translation-rules.md](../../../docs/i18n/translation-rules.md) and [terminology.md](../../../docs/i18n/terminology.md); the extended translation skill is outside automatic review and runs only on explicit user invocation. diff --git a/.agents/skills/dsh-pre-push-checks/SKILL.md b/.agents/skills/dsh-pre-push-checks/SKILL.md index 838e93e0b3..3d55a9dece 100644 --- a/.agents/skills/dsh-pre-push-checks/SKILL.md +++ b/.agents/skills/dsh-pre-push-checks/SKILL.md @@ -124,7 +124,7 @@ For GitHub PRs, inspect remote CI after the push: gh pr checks ``` -Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. A `weighted approval decision` check that `gh pr checks` lists as failed means the approval gate is waiting on reviewers only when its conclusion is `ACTION_REQUIRED`; the same check concluded `FAILURE` is a publisher fault, so read the `weighted approval publisher` job before attributing it to the branch. +Report pending checks as pending. Inspect failures before attributing them to the branch or the environment. When `gh pr checks` reports "no checks reported" and `/actions/runs?head_sha=` returns `total_count: 0`, read mergeability before suspecting the push or a dropped GitHub event: diff --git a/.agents/skills/dsh-responsive-ui/SKILL.md b/.agents/skills/dsh-responsive-ui/SKILL.md index a9ee2f3a37..73e0a8f944 100644 --- a/.agents/skills/dsh-responsive-ui/SKILL.md +++ b/.agents/skills/dsh-responsive-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: dsh-responsive-ui -description: Use when designing, implementing, or reviewing responsive client layouts, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness. +description: Use when designing, implementing, or reviewing responsive client layouts, mobile and touch detection, text truncation, title wrapping, icon-only controls, mobile hover, tooltips and keyboard behavior, and focus during width changes in DeepSeek Harness. --- # Responsive client UI @@ -39,10 +39,10 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must ## 6. Mobile hover behavior -1. Enable mouse-hover effects only on devices that support hovering. Use `@media (hover: hover)`, not viewport width, to determine this. -2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. -3. Preserve normal selected, enabled, and pressed states, as well as focus indicators for keyboard interaction. -4. Try to preserve focus when the width changes, unless the focused element disappears from the page as a result. +1. Enable hover effects under `@media (hover: hover)`, independently of viewport width. This queries the primary pointing device, not the current event; JavaScript hover handlers must ignore touch events on mixed-input devices. +2. Tapping on a phone must not leave behind backgrounds, highlights, or action-button visibility intended only for hover. Without hover, actions remain visible or reachable through an explicit control. Keyboard users can reach and reveal the same actions. +3. Preserve normal selected, expanded, enabled, and pressed states, as well as focus indicators. Keep these styles independent of hover capability. Do not clear focus to remove a hover effect; `:focus-visible` can also apply to text fields focused by touch. +4. Preserve focus during width changes when the control remains visible and interactive. Avoid remounting controls only to change layout. If the focused control is hidden or removed, move focus to a suitable remaining control without opening the mobile keyboard unnecessarily. ## 7. Mobile tooltips @@ -52,3 +52,8 @@ Use a viewport width of 390 CSS px as the primary acceptance minimum. There must ## 8. Mobile keyboard On mobile, do not raise the input keyboard automatically unless it is explicitly intended. + +## 9. Input capabilities and mobile text fields + +1. Prefer CSS media features for presentation: `(pointer: coarse)` describes the primary pointer's accuracy, `(hover: none)` describes its inability to hover, and width queries select layout breaks. These queries do not detect a physical keyboard. Never branch layout on a parsed user agent. +2. Text fields take a 16px floor under `(pointer: coarse)` to avoid iOS Safari's focus zoom. Fine pointers keep the existing compact field sizes. Use the field rules in `packages/client/ui-plugin-manager/src/client/PluginManagerPage.module.css` as the reference. diff --git a/.agents/skills/dsh-session-projection/SKILL.md b/.agents/skills/dsh-session-projection/SKILL.md new file mode 100644 index 0000000000..e709e70598 --- /dev/null +++ b/.agents/skills/dsh-session-projection/SKILL.md @@ -0,0 +1,19 @@ +--- +name: dsh-session-projection +description: Use when creating, extending, or reviewing a DeepSeek Harness session projection, or migrating a fold helper to one. +--- + +# DeepSeek Harness Session Projections + +Follow the [projection API](../../../packages/session/session-projection/README.md) for registration and lifecycle details. + +1. Decide whether to extend an existing projection or create one; check whether an existing owner already maintains the needed state. +2. Follow the README's [service ownership rules](../../../packages/session/session-projection/README.md#register-and-read): keep projection definitions and handles private to the owning service's implementation, and expose domain methods to consumers. +3. Require `sessionProjections` when the service needs projected state to function. Optional registration is for contributions the service can operate without. +4. Evaluate retained state size as the session grows. Do not accumulate state proportional to session length, including collections of event sequence numbers. Growth is acceptable only when slow enough to remain negligible. When the data would keep growing, consider incremental loading instead of retaining the complete state. +5. `apply` is synchronous; historical reads belong in asynchronous consumers. When later reads need event payloads, consider retaining sequence numbers within rule 4's state-size limits and letting consumers resolve them asynchronously. +6. When replacing a helper such as `foldSessionTitle` with a projection, remove the superseded helper. +7. After migrating to Session persistence, stop using `fold` in names and descriptions of the migrated logic. +8. Do not add calls to deprecated synchronous readers (`snapshotEvents`, `eventAt`, `ownEvents`) or wrappers exposing the same access ([decision](../../notes/implemented/architecture/2026-09-09-deprecate-synchronous-session-event-reads.md)). Assume the full event log is not resident in memory and random reads may require asynchronous disk access. +9. Consider state size, replay cost, and per-event work; add complexity only for a concrete performance need. +10. Asynchronous `register()` is usually called in `[Service.init]`. If event listeners are registered afterward, check initialization ordering: when needed, install listeners before awaiting registration; if a listener needs the returned handle, await its readiness in a callback that supports asynchronous waiting. Otherwise, an event may fire before the listener is installed and be missed. Determine whether this race exists from the actual initialization flow and event trigger paths; do not move listeners earlier or add waits mechanically. diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md index 58d9ee88d5..26ae70e6f6 100644 --- a/.github/review-ownership/README.md +++ b/.github/review-ownership/README.md @@ -16,11 +16,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes ## Approval scoring -The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and check publication completed, while the `weighted approval decision` check run carries the approval decision on the pull request head. Branch rules must require that check run with GitHub Actions as its expected source; a name-only requirement can accept a same-named check or status from another integration. The publisher updates the head's existing GitHub Actions decision check run and creates one only when none exists; a head without one starts `in_progress` and a re-evaluated head that published `success` is concluded `action_required` before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. A completed check run only ever changes conclusion, so every later write completes it again. Failures and cancellations that happen before the publish step records a conclusion conclude it as `failure`; a cancellation after that record keeps the published decision. A completed evaluation concludes `action_required` 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 that conclusion even when counted approvals reach the threshold. It concludes `success` only when the threshold is met, the pull request is ready, and no such blocker exists. `action_required` blocks merging but completes the check, so tools that wait for checks to finish do not wait for reviewers; the check-run title carries the score or blocker. +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’s own review and reviewers without write permission do not count. -The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 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 concludes the check as `failure`. +The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 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. @@ -28,13 +28,13 @@ Production source means supported code files under `src/` in `packages/`, `apps/ Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements. -The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before check-run writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though decision check runs target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the check run. Permission changes take effect on the next subscribed event. +The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Conversation comment events run the publisher only when the current or previous body contains `/delegate`. Closed or merged PRs and stale review heads are skipped before status writes or dependency setup; the workflow does not subscribe to master pushes. GitHub associates `workflow_run` publisher runs with the default branch even though approval statuses target the open PR head. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event. ## Delegating points -Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal concludes the check as `failure` and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests. +Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Each evaluation reconciles every active eligible command: it dismisses the sender's existing `APPROVED` and `CHANGES_REQUESTED` reviews, then requests review from recipients who have neither approved nor already been requested. This also handles commands posted on drafts once the PR becomes ready and commands whose original event was replaced in the concurrency queue. Other requested reviewers remain unchanged. The command does not submit a review; drafts do not dismiss or request reviews. Comment-only and pending reviews cannot be dismissed. A failed dismissal publishes an error and prevents review requests; scores are refreshed after successful dismissal. Request failures are logged without changing the published score, and subsequent evaluations retry outstanding requests. Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving eligible, author-authorized command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Submitting an inline diff comment with “Add single comment” also creates a comment-only review and reclaims the points. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive. An edited command is accepted only when GitHub identifies the original author as its latest editor; edits by other accounts cannot delegate or cancel that author’s points. @@ -44,7 +44,7 @@ Each sender contributes at most once, using their own fixed weight or production ## Security -All actions in the check-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 and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures conclude the check as `failure`; request failures remain separate from the approval decision. +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 and comments are treated as API data and escaped in logs. All publisher triggers and steps, including dependency setup, have pull-request write permission; the policy uses it to dismiss eligible senders' prior decision reviews and request recipients. Every evaluation verifies command authors and latest editors by immutable account ID through GraphQL, matching the body to the REST comment history before counting points or acting. Missing or inconsistent editor metadata fails evaluation. Non-author edits are ignored. Only active eligible delegations are reconciled; superseded or revoked commands have no effects. Dismissal failures publish an error; request failures remain separate from the approval decision. 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. @@ -52,7 +52,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, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, check-run 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, delegation conservation and revocation, editor authorization, comment pagination, dismissal and review-request reconciliation, blockers, drafts, closed-PR skips, 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/check-approval.mjs b/.github/review-ownership/check-approval.mjs index c18575809f..5792299c46 100644 --- a/.github/review-ownership/check-approval.mjs +++ b/.github/review-ownership/check-approval.mjs @@ -10,8 +10,7 @@ import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs' const API_VERSION = '2026-03-10' const MAX_PULL_REQUEST_RECORDS = 3_000 const PAGE_SIZE = 100 -const CHECK_NAME = 'weighted approval decision' -const PASSING_CONCLUSIONS = new Set(['success', 'neutral', 'skipped']) +const STATUS_CONTEXT = 'weighted approval' const WRITABLE_PERMISSIONS = new Set(['admin', 'write']) const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) @@ -210,13 +209,13 @@ function timestamp(value, subject) { /** * 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, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. - * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'action_required' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and check-run output fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated. + * @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, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number, reviewIds: number[], approved: boolean}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands and their superseded decision review IDs, and null author credit means history was not evaluated. */ export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) { const pull = pullRequestFromEvent(event) const policy = parseApprovalPolicy(policySource) if (pull.draft) { - return approvalResult(pull, policy.requiredPoints, [], [], [], 'action_required', 'draft pull request') + return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request') } const reviews = await listPullRequestReviews(api, pull.repository, pull.number) @@ -287,11 +286,11 @@ export async function evaluateApproval({ event, policySource, api, getOwnership return next }, authorCredit?.points ?? 0) if (blockers.length > 0) { - return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'action_required', + return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending', `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations) } // Tolerate floating-point addition error without rounding approval scores. - const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'action_required' + const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending' return approvalResult( pull, policy.requiredPoints, @@ -306,12 +305,13 @@ export async function evaluateApproval({ event, policySource, api, getOwnership } /** - * Evaluate and publish the required check run, concluding it as failure when evaluation fails. - * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, write?: (line: string) => void, record?: (conclusion: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs; record receives each conclusion the run publishes. + * 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, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs. * @returns {Promise>>} Published approval decision. */ -export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`), record = () => {} }) { +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, getMergedCount }) @@ -331,8 +331,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount }) } } catch (error) { - await publishCheck(api, pull, 'failure', 'Approval evaluation failed.', runUrl) - record('failure') + await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl) throw error } write(result.authorCredit @@ -343,9 +342,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw `@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`)) writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`)) writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`)) - await publishCheck(api, pull, result.state, result.description, runUrl) - record(result.state) - write(`Published ${JSON.stringify(CHECK_NAME)} check run ${JSON.stringify(result.state)}.`) + await publishStatus(api, pull, result.state, result.description, runUrl) + write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`) try { await requestDelegatedReviews(result, api, write) } catch (error) { @@ -355,15 +353,14 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw } /** - * Revoke a published success before dependency installation, or conclude a failed setup. - * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise, runUrl: string, phase: string}} options Publication inputs. - * @returns {Promise} Completion of the check-run write. + * Revoke a previous success before dependency installation, or report its failure. + * @param {{event: unknown, api: (path: string, options: object) => Promise, runUrl: string, phase: string}} options Publication inputs. + * @returns {Promise} Completion of the status write. */ export async function publishApprovalPhase({ event, api, runUrl, phase }) { - const pull = pullRequestFromEvent(event) - if (phase === 'revoke') return revokeApproval(api, pull, runUrl) - if (phase === 'failure') return publishCheck(api, pull, 'failure', 'Approval setup or evaluation failed.', runUrl) - throw new Error('invalid approval setup phase') + if (!['pending', 'error'].includes(phase)) throw new Error('invalid approval setup phase') + await publishStatus(api, pullRequestFromEvent(event), phase, + phase === 'pending' ? 'Preparing approval evaluation.' : 'Approval setup or evaluation failed.', runUrl) } /** @@ -417,7 +414,7 @@ export async function approvalEventFromComment({ event, api }) { } /** - * Refresh a pull-request event before any check-run write; closed PRs and stale heads are skipped. + * Refresh a pull-request event before any status write; closed PRs and stale heads are skipped. * @param {{event: unknown, api: (path: string) => Promise}} options Pull-request event and API caller. * @returns {Promise | null>} Live event, or null for a closed PR or superseded head. */ @@ -470,60 +467,18 @@ async function reviewerPermission(api, repository, login) { return response.permission.toLowerCase() } -// One decision per head keeps branch rules and CI watchers unambiguous; the head's existing GitHub -// Actions run is updated and a run is created only when none exists. -async function publishCheck(api, pull, conclusion, title, runUrl) { - requireHttpsRunUrl(runUrl) - const body = { status: 'completed', conclusion, details_url: runUrl, output: { title, summary: `${title} [Workflow run](${runUrl})` } } - const runs = await decisionRuns(api, pull) - if (runs.length === 0) { - await api(`/repos/${pull.repository}/check-runs`, { method: 'POST', body: { name: CHECK_NAME, head_sha: pull.headSha, ...body } }) - return - } - for (const { id } of runs) { - if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id') - await api(`/repos/${pull.repository}/check-runs/${id}`, { method: 'PATCH', body }) - } -} - -// A head without a decision starts in progress; a published success becomes action_required, so an -// interrupted evaluation cannot leave a success that its current inputs no longer support. -async function revokeApproval(api, pull, runUrl) { - requireHttpsRunUrl(runUrl) - const title = 'Preparing approval evaluation.' - const output = { title, summary: `${title} [Workflow run](${runUrl})` } - const runs = await decisionRuns(api, pull) - if (runs.length === 0) { - await api(`/repos/${pull.repository}/check-runs`, { - method: 'POST', - body: { name: CHECK_NAME, head_sha: pull.headSha, status: 'in_progress', details_url: runUrl, output }, - }) - return - } - for (const { id, conclusion } of runs) { - if (!Number.isSafeInteger(id)) throw new Error('check run has no valid id') - // Revoke every conclusion a required check accepts, not only the success this publisher writes: - // a workflow job added by the pull request can produce a same-named passing check run. - if (!PASSING_CONCLUSIONS.has(conclusion)) continue - await api(`/repos/${pull.repository}/check-runs/${id}`, { - method: 'PATCH', - body: { status: 'completed', conclusion: 'action_required', details_url: runUrl, output }, - }) - } -} - -// A check run that reached completed never returns to in_progress: GitHub answers 200 and leaves the -// run completed, so a published decision can only be replaced by another conclusion. -async function decisionRuns(api, pull) { - const listed = await api(`/repos/${pull.repository}/commits/${pull.headSha}/check-runs?check_name=${encodeURIComponent(CHECK_NAME)}&filter=all&per_page=${PAGE_SIZE}`) - if (!isRecord(listed) || !Array.isArray(listed.check_runs) || listed.total_count !== listed.check_runs.length) { - throw new Error('check-run list response is invalid or incomplete') - } - return listed.check_runs.filter(run => isRecord(run) && isRecord(run.app) && run.app.slug === 'github-actions') -} - -function requireHttpsRunUrl(runUrl) { +async function publishStatus(api, pull, state, description, runUrl) { if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS') + if (description.length > 140) throw new Error('commit status description exceeds 140 characters') + await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, { + method: 'POST', + body: { + state, + context: STATUS_CONTEXT, + description, + target_url: runUrl, + }, + }) } function pullRequestFromEvent(event) { @@ -616,7 +571,7 @@ async function main() { const phase = process.argv[2] if (phase) { await publishApprovalPhase({ event, api, runUrl: process.env.GITHUB_RUN_URL ?? '', phase }) - if (phase === 'revoke' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') + if (phase === 'pending' && process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'active=true\n') return } await runApprovalCheck({ @@ -624,9 +579,6 @@ async function main() { policySource, api, runUrl: process.env.GITHUB_RUN_URL ?? '', - record: (conclusion) => { - if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, `concluded=${conclusion}\n`) - }, }) } diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs index 72b80d6681..b5d0b6cbb3 100644 --- a/.github/review-ownership/check-approval.test.mjs +++ b/.github/review-ownership/check-approval.test.mjs @@ -11,20 +11,14 @@ import { evaluateApproval as evaluateWithHistory, listPullRequestReviews, parseApprovalPolicy, - runApprovalCheck as runPublisher, + runApprovalCheck as runWithHistory, publishApprovalPhase, } from './check-approval.mjs' const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8') -const emptyCheckRuns = { total_count: 0, check_runs: [] } -const isCheckRunsList = path => path.includes('/commits/') && path.includes('/check-runs?') -const withoutCheckRuns = api => (path, options) => - isCheckRunsList(path) ? emptyCheckRuns : api(path, options) -const withEmptyReads = api => withoutCheckRuns((path, options) => path.includes('/comments?') ? [] : api(path, options)) -const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) }) -const runWithHistory = options => runPublisher({ ...options, api: withoutCheckRuns(options.api) }) -const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withEmptyReads(options.api) }) -const publishedState = body => body.conclusion ?? body.status +const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options) +const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) +const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) }) const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678' const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({ @@ -204,7 +198,7 @@ test('accepts two one-point approvals and ignores reviews without write access', assert.deepEqual(result.ignoredReviewers, ['reader']) }) -test('requires action for one one-point approval without failing the check', async () => { +test('keeps one one-point approval pending without failing the status', async () => { const result = await evaluateApproval({ event: pullRequestEvent(), policySource, @@ -215,7 +209,7 @@ test('requires action for one one-point approval without failing the check', asy throw new Error(`unexpected API path ${path}`) }, }) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.equal(result.points, 1) }) @@ -234,12 +228,12 @@ test('ignores a reviewer whose collaborator permission lookup returns 404', asyn }, }) const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api }) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.deepEqual(result.ignoredReviewers, ['former-writer']) }) -test('requires action on a write-capable change request while ignoring the author and read-only reviewers', async () => { - const writes = [] +test('keeps the status pending on a write-capable change request while ignoring the author and read-only reviewers', async () => { + const statuses = [] const result = await runApprovalCheck({ event: pullRequestEvent({ author: 'author' }), policySource, @@ -257,43 +251,45 @@ test('requires action on a write-capable change request while ignoring the autho if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' } if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' } if (path.includes('/collaborators/reader/permission')) return { permission: 'read' } - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { - writes.push(options.body) + if (path.includes('/statuses/')) { + statuses.push(options.body) return {} } throw new Error(`unexpected API path ${path}`) }, write: () => {}, }) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.equal(result.description, '1 blocking change request.') assert.equal(result.points, 2) assert.deepEqual(result.blockers, ['blocker']) assert.deepEqual(result.ignoredReviewers, ['reader']) - assert.deepEqual(writes, [{ - name: 'weighted approval decision', - head_sha: HEAD_SHA, - status: 'completed', - conclusion: 'action_required', - details_url: 'https://github.example/actions/runs/1', - output: { title: '1 blocking change request.', summary: '1 blocking change request. [Workflow run](https://github.example/actions/runs/1)' }, + assert.deepEqual(statuses, [{ + state: 'pending', + context: 'weighted approval', + description: 'Evaluating approval points.', + target_url: 'https://github.example/actions/runs/1', + }, { + state: 'pending', + context: 'weighted approval', + description: '1 blocking change request.', + target_url: 'https://github.example/actions/runs/1', }]) }) -test('requires action on drafts without reading reviews', async () => { +test('keeps drafts pending without reading reviews', async () => { const result = await evaluateApproval({ event: pullRequestEvent({ draft: true }), policySource, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), api: async () => { throw new Error('draft evaluation must not call GitHub') }, }) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.equal(result.points, 0) assert.match(result.description, /draft pull request/u) }) -test('publishes the decision check run and concludes failure when evaluation fails', async () => { +test('publishes the required status and replaces stale success with error on evaluation failure', async () => { const calls = [] const output = [] const result = await runApprovalCheck({ @@ -305,24 +301,21 @@ test('publishes the decision check run and concludes failure when evaluation fai calls.push({ path, options }) if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' } - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} throw new Error(`unexpected API path ${path}`) }, write: line => output.push(line), }) assert.equal(result.state, 'success') assert.deepEqual(calls.at(-1), { - path: '/repos/deepseek-harness/deepseek-harness/check-runs', + path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`, options: { method: 'POST', body: { - name: 'weighted approval decision', - head_sha: HEAD_SHA, - status: 'completed', - conclusion: 'success', - details_url: 'https://github.example/actions/runs/1', - output: { title: '2/2 approval points.', summary: '2/2 approval points. [Workflow run](https://github.example/actions/runs/1)' }, + state: 'success', + context: 'weighted approval', + description: '2/2 approval points.', + target_url: 'https://github.example/actions/runs/1', }, }, }) @@ -337,8 +330,7 @@ test('publishes the decision check run and concludes failure when evaluation fai runUrl: 'https://github.example/actions/runs/2', api: async (path, options = {}) => { if (path.includes('/reviews?')) throw new Error('reviews unavailable') - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { + if (path.includes('/statuses/')) { failures.push({ path, options }) return {} } @@ -346,10 +338,9 @@ test('publishes the decision check run and concludes failure when evaluation fai }, write: () => {}, }), /reviews unavailable/u) - assert.deepEqual(failures.map(({ path }) => path), ['/repos/deepseek-harness/deepseek-harness/check-runs']) - assert.equal(failures[0].options.body.status, 'completed') - assert.equal(failures[0].options.body.conclusion, 'failure') - assert.equal(failures[0].options.body.output.title, 'Approval evaluation failed.') + assert.equal(failures[0].options.body.state, 'pending') + assert.equal(failures[1].options.body.state, 'error') + assert.equal(failures[1].options.body.description, 'Approval evaluation failed.') }) test('sends authenticated JSON and escapes an API error body', async () => { @@ -392,7 +383,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999 }) assert.equal(result.points, expectedPoints) assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`) - assert.equal(result.state, expectedPoints === 2 ? 'success' : 'action_required') + assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending') assert.equal(measurements, 1) assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines }) }) @@ -412,7 +403,7 @@ test('does not fetch ownership when a change request blocks approval', async () }) assert.equal(measurements, 0) assert.equal(result.points, 2) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') }) test('does not fetch history when approvals already have two-point weights', async () => { @@ -424,7 +415,7 @@ test('does not fetch history when approvals already have two-point weights', asy assert.equal(result.points, 2) }) -test('concludes failure when production attribution fails', async () => { +test('publishes error when production attribution fails', async () => { const states = [] await assert.rejects(runApprovalCheck({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', @@ -433,46 +424,30 @@ test('concludes failure when production attribution fails', async () => { if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } if (path.includes('/comments?')) return [] - if (isCheckRunsList(path)) return emptyCheckRuns - states.push(publishedState(options.body)) + states.push(options.body.state) return {} }, }), /incomplete history/u) - assert.deepEqual(states, ['failure']) + assert.deepEqual(states, ['pending', 'error']) }) -test('revokes a published success without returning a completed run to in progress', async () => { - const calls = [] - const checkRuns = [ - { id: 7, app: { slug: 'github-actions' }, conclusion: 'success' }, - { id: 8, app: { slug: 'github-actions' }, conclusion: 'action_required' }, - { id: 9, app: { slug: 'other-app' }, conclusion: 'success' }, - { id: 10, app: { slug: 'github-actions' }, conclusion: 'skipped' }, - { id: 11, app: { slug: 'github-actions' }, conclusion: 'neutral' }, - { id: 12, app: { slug: 'github-actions' }, conclusion: null }, - ] - await publishApprovalPhase({ - event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'revoke', +test('revokes a previous success before starting expensive attribution', async () => { + const states = [] + await runApprovalCheck({ + event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, + getOwnership: async () => { + assert.deepEqual(states, ['pending']) + return { totalLines: 100, reviewerLines: { writer: 25 } } + }, api: async (path, options) => { - calls.push({ path, method: options?.method, body: options?.body }) - return isCheckRunsList(path) ? { total_count: checkRuns.length, check_runs: checkRuns } : {} + if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] + if (path.includes('/permission')) return { permission: 'write' } + states.push(options.body.state) + return {} }, }) - assert.deepEqual(calls.map(({ path }) => path), [ - `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, - '/repos/deepseek-harness/deepseek-harness/check-runs/7', - '/repos/deepseek-harness/deepseek-harness/check-runs/10', - '/repos/deepseek-harness/deepseek-harness/check-runs/11', - ]) - for (const call of calls.slice(1)) { - assert.deepEqual(call.body, { - status: 'completed', - conclusion: 'action_required', - details_url: 'https://github.example/run/1', - output: { title: 'Preparing approval evaluation.', summary: 'Preparing approval evaluation. [Workflow run](https://github.example/run/1)' }, - }) - } + assert.deepEqual(states, ['pending', 'success']) }) for (const reviewers of [['first', 'second'], ['turtle1999', 'first']]) { @@ -502,67 +477,14 @@ test('publishes setup phases without evaluating or installing dependencies', asy const states = [] const options = { event: pullRequestEvent(), runUrl: 'https://github.example/run/1', - api: withEmptyReads(async (path, { body }) => { assert.match(path, /\/check-runs$/u); states.push(publishedState(body)) }), + api: async (path, { body }) => { assert.match(path, /\/statuses\//u); states.push(body.state) }, } - await publishApprovalPhase({ ...options, phase: 'revoke' }) - await publishApprovalPhase({ ...options, phase: 'failure' }) - assert.deepEqual(states, ['in_progress', 'failure']) + await publishApprovalPhase({ ...options, phase: 'pending' }) + await publishApprovalPhase({ ...options, phase: 'error' }) + assert.deepEqual(states, ['pending', 'error']) await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u) }) -test('updates the head\'s GitHub Actions decision check run instead of creating another', async () => { - const calls = [] - const checkRuns = [ - { id: 7, app: { slug: 'github-actions' }, conclusion: 'action_required' }, - { id: 8, app: { slug: 'other-app' }, conclusion: 'action_required' }, - ] - await publishApprovalPhase({ - event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', - api: async (path, options) => { - calls.push({ path, method: options?.method }) - return { total_count: checkRuns.length, check_runs: checkRuns } - }, - }) - assert.deepEqual(calls, [ - { path: `/repos/deepseek-harness/deepseek-harness/commits/${HEAD_SHA}/check-runs?check_name=weighted%20approval%20decision&filter=all&per_page=100`, method: undefined }, - { path: '/repos/deepseek-harness/deepseek-harness/check-runs/7', method: 'PATCH' }, - ]) - for (const response of [{}, { total_count: 101, check_runs: [] }, { total_count: 1, check_runs: [{ id: '7', app: { slug: 'github-actions' } }] }]) { - await assert.rejects(publishApprovalPhase({ - event: pullRequestEvent(), runUrl: 'https://github.example/run/1', phase: 'failure', api: async () => response, - }), /check-run list response|valid id/u) - } -}) - -test('records each published conclusion so the failure step never replaces it', async () => { - const recorded = [] - const api = async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')] - if (path.includes('/permission')) return { permission: 'write' } - if (path.includes('/comments?')) return [] - assert.match(path, /\/check-runs$/u) - return {} - } - await runApprovalCheck({ - event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', api, write: () => {}, - record: conclusion => recorded.push(conclusion), - }) - await assert.rejects(runApprovalCheck({ - event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/2', write: () => {}, - getMergedCount: async () => { throw new Error('history unavailable') }, - record: conclusion => recorded.push(conclusion), - api: async path => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/reviews?')) return [] - if (path.includes('/comments?')) return [] - return {} - }, - }), /history unavailable/u) - assert.deepEqual(recorded, ['success', 'failure']) -}) - - for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) { test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => { const result = await evaluateApproval({ @@ -571,7 +493,7 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089] }) assert.equal(result.points, credit) assert.equal(result.authorCredit.points, credit) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.deepEqual(result.approvals, []) }) } @@ -587,7 +509,7 @@ test('combines author credit and reviewer ownership at the passing threshold', a } }) -for (const [mergedCount, state] of [[90, 'action_required'], [91, 'success'], [100, 'success']]) { +for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) { test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => { const result = await evaluateApproval({ event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount, @@ -610,14 +532,14 @@ test('blockers skip history and ignore non-write reviewers', async () => { ? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')] : { permission: path.includes('/reader/') ? 'read' : 'write' }, }) - assert.equal(result.state, 'action_required') + 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 a failed history lookup concludes the check as failure', async () => { +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') }, @@ -628,20 +550,20 @@ test('drafts skip history and a failed history lookup concludes the check as fai getMergedCount: async () => { throw new Error('history unavailable') }, api: async (path, options) => { if (path.includes('/reviews?')) return [] - states.push(publishedState(options.body)) + states.push(options.body.state) }, }), /history unavailable/u) - assert.deepEqual(states, ['failure']) + assert.deepEqual(states, ['pending', 'error']) }) -test('a score just below the threshold requires action even if its display rounds to two', async () => { +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 () => 50, getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }), api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' }, }) - assert.equal(result.state, 'action_required') + assert.equal(result.state, 'pending') assert.ok(result.points < 2) }) @@ -665,14 +587,14 @@ test('the publisher counts merged history through the production API path', asyn } if (path.includes('/reviews?')) return [review('writer', 'APPROVED')] if (path.includes('/permission')) return { permission: 'write' } - states.push(publishedState(options.body)) + states.push(options.body.state) return {} }, }) assert.equal(result.points, 2) assert.equal(result.authorCredit.points, 0.55) assert.equal(result.approvals[0].points, 1.45) - assert.deepEqual(states, ['success']) + assert.deepEqual(states, ['pending', 'success']) assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).') }) @@ -690,7 +612,7 @@ test('sufficient reviewer points and drafts publish without querying author hist return {} }, }) - assert.equal(result.state, draft ? 'action_required' : 'success') + 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'}).`) } @@ -756,7 +678,7 @@ test('delegates the sender fixed points once after the recipient approves, retai }) }) const points = recipientState === 'APPROVED' ? 3 : 0 assert.equal(result.points, points) - assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'action_required') + assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending') assert.deepEqual(result.blockers, [ ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []), ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []), @@ -864,7 +786,7 @@ test('a sender submitted review takes back delegation, including comment-only an }) }) assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0) assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1) - assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'action_required') + assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending') if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined) } } @@ -907,8 +829,7 @@ test('an active delegate command requests review once and preserves other reques if (options?.method === 'POST') { requests.push(options.body); return {} } return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] } } - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} return api(path, options) }, }) @@ -936,8 +857,7 @@ test('inactive commands and drafts do not request review', async () => { policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {}, api: async (path, options) => { assert.ok(!path.endsWith('/requested_reviewers')) - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} return api(path, options) }, }) @@ -953,8 +873,7 @@ test('failed review requests preserve the computed score and report the retry', event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }), write: line => output.push(line), api: async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } + if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} } if (path.endsWith('/requested_reviewers')) { if (options?.method === 'POST') throw new Error('request rejected') return failedRead ? {} : { users: [] } @@ -964,7 +883,7 @@ test('failed review requests preserve the computed score and report the retry', }) assert.match(output.at(-1), failedRead ? /no users array/u : /request rejected/u) assert.match(output.at(-1), /score is unchanged.*will retry/u) - assert.deepEqual(statuses, ['success']) + assert.deepEqual(statuses, ['pending', 'success']) } }) @@ -976,8 +895,7 @@ test('delegation dismisses only the sender old decisions and stays active across const states = [] const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews }) const api = async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } + if (path.includes('/statuses/')) { states.push(options.body.state); return {} } if (path.endsWith('/dismissals')) { calls.push(options) const id = Number(path.split('/').at(-2)) @@ -1001,13 +919,13 @@ test('delegation dismisses only the sender old decisions and stays active across assert.deepEqual(result.blockers, ['other']) assert.equal(result.delegations.length, 1) assert.deepEqual(result.delegations[0].reviewIds, []) - assert.deepEqual(states, ['action_required']) + assert.deepEqual(states, ['pending', 'pending']) reviews.find(review => review.id === 21).state = 'DISMISSED' for (const state of ['APPROVED', 'DISMISSED', 'COMMENTED', 'APPROVED']) { reviews.find(review => review.id === 20).state = state const next = await evaluateDelegation({ api }) assert.equal(next.delegations.length, 1) - assert.equal(next.state, state === 'APPROVED' ? 'success' : 'action_required') + assert.equal(next.state, state === 'APPROVED' ? 'success' : 'pending') } reviews.push(review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z', 30)) const reclaimed = await evaluateDelegation({ api }) @@ -1021,8 +939,7 @@ test('a new sender review arriving during dismissal is not dismissed or delegate const result = await runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, getMergedCount: async () => 0, api: async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} if (path.endsWith('/dismissals')) { assert.ok(path.endsWith('/reviews/10/dismissals')) reviews[0].state = 'DISMISSED' @@ -1037,15 +954,14 @@ test('a new sender review arriving during dismissal is not dismissed or delegate assert.deepEqual(result.blockers, ['turtle1999']) }) -test('a failed or unconfirmed dismissal prevents review requests and concludes failure', async () => { +test('a failed or unconfirmed dismissal prevents review requests and publishes error', async () => { for (const rejected of [false, true]) { const states = [] const read = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')], reviews: [review('turtle1999', 'CHANGES_REQUESTED')] }) await assert.rejects(runWithHistory({ event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }, policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { states.push(publishedState(options.body)); return {} } + if (path.includes('/statuses/')) { states.push(options.body.state); return {} } if (path.endsWith('/dismissals')) { if (rejected) throw new Error('dismissal denied') return { id: 10, state: 'CHANGES_REQUESTED' } @@ -1054,7 +970,7 @@ test('a failed or unconfirmed dismissal prevents review requests and concludes f return read(path, options) }, }), rejected ? /dismissal denied/u : /dismissal was not confirmed/u) - assert.deepEqual(states, ['failure']) + assert.deepEqual(states, ['pending', 'error']) } }) @@ -1067,8 +983,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: line => output.push(line), api: async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) { statuses.push(publishedState(options.body)); return {} } + if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} } if (path.includes('/comments?')) return comments(path) return api(path, options) }, @@ -1081,7 +996,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing }) assert.equal(pages.length, 2) assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)')) - assert.deepEqual(statuses.splice(0), ['success']) + assert.deepEqual(statuses.splice(0), ['pending', 'success']) for (const [comments, message] of [ [() => { throw new Error('comment API unavailable') }, /comment API unavailable/u], [() => ({}), /comments response is not an array/u], @@ -1091,7 +1006,7 @@ test('reads all comment pages, logs the score owner, and fails closed on missing [() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u], ]) { await assert.rejects(run(comments), message) - assert.deepEqual(statuses.splice(0), ['failure']) + assert.deepEqual(statuses.splice(0), ['pending', 'error']) } }) @@ -1175,8 +1090,7 @@ test('later events reconcile all active delegations after a draft or replaced co const effects = [] const requested = [] const api = async (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} if (path.endsWith('/dismissals')) { const old = reviews.find(review => path.endsWith(`/reviews/${review.id}/dismissals`)) effects.push(old.id) @@ -1208,8 +1122,7 @@ test('already approved delegates need no new request', async () => { }) const result = await runWithHistory({ event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1', write: () => {}, api: (path, options) => { - if (isCheckRunsList(path)) return emptyCheckRuns - if (path.includes('/check-runs')) return {} + if (path.includes('/statuses/')) return {} assert.ok(!path.endsWith('/requested_reviewers')) return read(path, options) }, diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml index e406d5ee2a..eb6f2fe3ba 100644 --- a/.github/workflows/weighted-approval.yml +++ b/.github/workflows/weighted-approval.yml @@ -10,9 +10,9 @@ on: types: [completed] permissions: - checks: write contents: read pull-requests: write + statuses: write concurrency: group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }} @@ -29,20 +29,20 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: - # SECURITY: the check-writing job executes policy from the trusted default + # SECURITY: the status-writing job executes policy from the trusted default # branch and reads pull-request reviews and comments only as API data. - name: Check out trusted approval policy uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: ref: ${{ github.event.repository.default_branch }} persist-credentials: false - - name: Revoke previous approval decision + - name: Revoke previous approval status id: revoke env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: node .github/review-ownership/check-approval.mjs revoke - # SECURITY: dependency setup runs in the check-writing job. + run: node .github/review-ownership/check-approval.mjs pending + # SECURITY: dependency setup runs in the status-writing job. - if: steps.revoke.outputs.active == 'true' uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: @@ -52,19 +52,15 @@ jobs: - name: Install production lexer if: steps.revoke.outputs.active == 'true' run: python3 -m pip install -r .github/review-ownership/requirements.txt - - name: Publish weighted approval decision - id: publish + - name: Publish weighted approval status if: steps.revoke.outputs.active == 'true' env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: node .github/review-ownership/check-approval.mjs - # A failed or cancelled run must not leave an earlier conclusion standing. The publish step - # records the conclusion it wrote, so a decision already published survives a later failure or - # cancellation, and a cancelled publish step that wrote nothing still concludes failure. - name: Publish approval setup failure - if: (failure() || cancelled()) && steps.publish.outputs.concluded == '' + if: failure() && steps.revoke.outputs.active == 'true' env: GITHUB_TOKEN: ${{ github.token }} GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - run: node .github/review-ownership/check-approval.mjs failure + run: node .github/review-ownership/check-approval.mjs error diff --git a/AGENTS.md b/AGENTS.md index 5ee465f84d..1f88aa1293 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,7 @@ Real-API tests/demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, and - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. +- **Session projections:** follow [dsh-session-projection](.agents/skills/dsh-session-projection/SKILL.md) when creating, extending, or reviewing one, or migrating a fold helper to one. - **Plugins, not loop changes**: new behavior goes on documented extension points; changing `agent-loop` requires updating docs/architecture.md. - **A capability seam comprises Service Definition / Service Provider / Consumer roles.** It is complete, never one role; split only when roles evolve independently ([glossary](docs/glossary.md#capability-seam)). - **Prefer maintained dependencies over hand-rolling** when they genuinely delete owned code and tests ([policy](.agents/notes/implemented/process/2026-07-26-dependencies-over-hand-rolling.md)). diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 781c08b12c..cda1e0bb96 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -40,8 +40,6 @@ flowchart LR cfg --> plugin_dsh_base_user_questions plugin_dsh_base_agent["agent
@deepseek-ai/dsh-agent"] cfg --> plugin_dsh_base_agent - plugin_dsh_base_agent_controller["agent-controller
@deepseek-ai/dsh-agent-controller"] - cfg --> plugin_dsh_base_agent_controller plugin_dsh_base_plugin_package_inventory_deepseek["plugin-package-inventory-deepseek
@deepseek-ai/dsh-plugin-package-inventory-deepseek"] cfg --> plugin_dsh_base_plugin_package_inventory_deepseek plugin_dsh_base_agent_default_model["agent-default-model
@deepseek-ai/dsh-agent-default-model"] @@ -220,7 +218,6 @@ flowchart LR | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-prompt-llm` | | `user-questions` | `@deepseek-ai/dsh-user-questions` | | `agent` | `@deepseek-ai/dsh-agent` | -| `agent-controller` | `@deepseek-ai/dsh-agent-controller` | | `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` | | `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` | | `jobs` | `@deepseek-ai/dsh-jobs-local` | diff --git a/apps/web/tests/README.i18n.yaml b/apps/web/tests/README.i18n.yaml index 9c182e45e0..ed5933af9f 100644 --- a/apps/web/tests/README.i18n.yaml +++ b/apps/web/tests/README.i18n.yaml @@ -9,8 +9,8 @@ en: 1f9c004f3eb465fb zh: 3e45ef8014bad48d /apps-web-browser-e2e/these-are-host-face-tests: - en: 8c406d423c8e2137 - zh: a66a144fd146f56c + en: 101d687b1d98c5b7 + zh: f8c31115e8a15be7 /apps-web-browser-e2e/do-not-import-deepseek-ai-dsh-client-here: en: 7cc75a6413647142 zh: a8c541f9b93e9f7f diff --git a/apps/web/tests/README.md b/apps/web/tests/README.md index bfcba1ad90..de7d2e1389 100644 --- a/apps/web/tests/README.md +++ b/apps/web/tests/README.md @@ -32,6 +32,8 @@ Explicit scrolling uses `scrollIntoView` from `support.ts`: it resolves the loca They type-check in the root `tsconfig.host.json`, not in the Client aggregate, because they read Host services directly: `ctx.connection`, the Host `SessionStore`, and `ctx.sessionProjectionCache`. Driving a browser at runtime does not make a file part of the Client program — the two faces merge Cordis `Context` under the same keys with different services, so one program cannot see both. Moving these files into the Client aggregate makes every Host-service access fail to compile. +A new scenario joins both program lists in the same change: its file in the root `tsconfig.host.json` include and in the Client aggregate's `apps/web/tsconfig.json` exclude. That project includes all of `tests`, so a file missing from the exclude compiles as Client code, and the Client compiler gate of `pnpm run typecheck` keeps failing with Host-service errors until it is listed. + ## Do not import `@deepseek-ai/dsh-client-*` here Importing a Client package — a value or a type — pulls its TypeScript project and referenced projects into the Host build graph. Generated Remote declarations are available before package compilation, but this does not make the Host and Client Cordis `Context` declarations compatible. Keep these tests on the Host face and use only the explicit built-client harness exception below. diff --git a/apps/web/tests/README.zh.md b/apps/web/tests/README.zh.md index 24d8bdfd5d..db5869542a 100644 --- a/apps/web/tests/README.zh.md +++ b/apps/web/tests/README.zh.md @@ -32,6 +32,8 @@ pnpm --filter @deepseek-ai/dsh-web-frontend exec playwright install --with-deps 它们在根 `tsconfig.host.json` 中做类型检查,而不在 Client aggregate 中,因为它们直接读取 Host 服务:`ctx.connection`、Host 侧 `SessionStore` 与 `ctx.sessionProjectionCache`。运行时驱动浏览器并不使一个文件成为 Client 程序的一部分——两个 face 在相同的键上以不同服务合并 Cordis `Context`,因此单个程序无法同时看见两者。把这些文件挪进 Client aggregate 会让每一处 Host 服务访问都无法编译。 +新增场景要在同一次改动里登记到两份工程清单:根 `tsconfig.host.json` 的 include,以及 Client aggregate `apps/web/tsconfig.json` 的 exclude。该工程包含整个 `tests` 目录,漏登记 exclude 的文件会按 Client 代码编译,`pnpm run typecheck` 的 Client compiler 关卡会一直以 Host 服务错误失败,直到它被列入。 + ## 不要在此 import `@deepseek-ai/dsh-client-*` import 一个 Client 包——无论值还是类型——都会把它的 TypeScript 工程及其引用的工程拉进 Host 构建图。生成的 Remote 声明在包编译之前就绪,但这并不使 Host 与 Client 的 Cordis `Context` 声明兼容。保留这些测试的 Host face 归属,只使用下文明确的 built-client harness 例外。 diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index 9cc9cf1f16..f08cd4a54b 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -8,8 +8,8 @@ import { readProfileManifest } from '@deepseek-ai/dsh-app-boot' import { SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-ptc-runtime' import type {} from '@deepseek-ai/dsh-agent-preset-registry' -import { expect, it } from 'vitest' -import { launchWebScaffold, type WebScaffold } from './scaffold.ts' +import { expect, it, vi } from 'vitest' +import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { connectFreshWorkspaceZh, ZH_BROWSER_LOCALE } from './support.ts' it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserving default %s until the explicit persistent-runtime action', async (selectedDefault) => { @@ -54,9 +54,28 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await card.textContent()).toContain('请切换到 PTC 模式') + const enabling = Promise.withResolvers() + const setEnabled = scaffold.ctx.pluginManager.setBundleEnabled.bind(scaffold.ctx.pluginManager) + let applying: ReturnType | undefined + // Hold the Host request so the pending UI does not depend on machine speed; release into the real operation. + const enableCall = vi.spyOn(scaffold.ctx.pluginManager, 'setBundleEnabled').mockImplementationOnce((name, enabled) => { + applying = enabling.promise.then(() => setEnabled(name, enabled)) + return applying + }) + try { + await toggle.click() + await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor() + expect(await toggle.isEnabled()).toBe(false) + await compareOrRefreshGolden(fileURLToPath(new URL('./expected/cua-permissions/enabling.expected.md', import.meta.url)), + await captureStableAria(page, '[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]', scaffold.workspaceCwd), webSnapshotMode()) + } finally { + enabling.resolve(undefined) + enableCall.mockRestore() + await applying + } // Bundle transitions await native cleanup under the browser action deadline. - await toggle.click() await toggle.and(page.locator('[aria-checked="true"]')).waitFor({ state: 'visible' }) + await card.getByRole('status').getByText('正在应用 Computer Use 的更改', { exact: true }).waitFor({ state: 'hidden' }) const setup = page.getByRole('dialog', { name: '使用 Computer Use 前需要设置', exact: true }) await setup.waitFor() expect(await setup.getByText('Computer Use 插件已开启,但暂时无法检测系统权限。请前往插件详情页查看原因并重试。', { exact: true }).count()).toBe(1) diff --git a/apps/web/tests/cua-permissions.e2e.ts b/apps/web/tests/cua-permissions.e2e.ts index 18072ae8b1..e02b076eb6 100644 --- a/apps/web/tests/cua-permissions.e2e.ts +++ b/apps/web/tests/cua-permissions.e2e.ts @@ -7,9 +7,9 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm' import type {} from '@deepseek-ai/dsh-user-approval' import type {} from '@deepseek-ai/dsh-agent-preset-registry' import { chromium, type Browser } from 'playwright' -import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' -import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' +import { connectFreshWorkspace, newEnglishPage, PLUGIN_TOGGLE_SETTLE_MS, saveFailureShot } from './support.ts' import { installNativeFixture, startFixtureHost } from '../../../packages/experimental/cua-macos/tests/native-fixture.ts' const bundle = fileURLToPath(new URL('../../../packages/experimental/cua-bundle', import.meta.url)) @@ -146,5 +146,96 @@ it.skipIf(process.platform !== 'darwin')('keeps system requests pending until fr { method: 'openPermissionSettings', params: { permission: 'accessibility' } }, ]) expect(events.every(event => ['permissionStatus', 'requestPermission', 'openPermissionSettings', 'applicationIcons', 'getApp'].includes(event.method))).toBe(true) + await plugins.locator('[data-detail-document] h1').waitFor() + const trace = await page.evaluateHandle(() => { + const errors: string[] = [] + const stable = [...document.querySelectorAll('[data-cua-settings] [role="alert"]')].map(node => node.textContent?.trim()) + const layouts: { height: number; sections: number[]; groups: number[] }[] = [] + const documents: number[] = [] + let frame = 0 + const measure = () => { + const documentHeight = document.querySelector('[data-plugin-detail] [data-detail-document]')?.getBoundingClientRect().height ?? 0 + if (documents.at(-1) !== documentHeight) documents.push(documentHeight) + const settings = document.querySelector('[data-cua-settings]') + if (settings) { + const height = settings.getBoundingClientRect().height + if (layouts.at(-1)?.height !== height) layouts.push({ height, + sections: [...settings.querySelectorAll('section')].map(node => node.getBoundingClientRect().height), + groups: [...settings.querySelectorAll('[role="group"]')].map(node => node.getBoundingClientRect().height), + }) + } + frame = requestAnimationFrame(measure) + } + measure() + const observer = new MutationObserver(() => { + for (const node of document.querySelectorAll('[data-cua-settings] [role="alert"]')) { + const text = node.textContent?.trim() + if (text && !stable.includes(text) && errors.at(-1) !== text) errors.push(text) + } + }) + observer.observe(document.body, { childList: true, subtree: true, characterData: true }) + return { errors, layouts, documents, stop: () => { observer.disconnect(); cancelAnimationFrame(frame) } } + }) + try { + const toggle = page.locator('[data-plugin-panel]').getByRole('switch', { name: 'Enable Computer Use', exact: true }) + for (const width of [1680, 390]) { + await page.setViewportSize({ width, height: 1000 }) + if (width === 390) await page.locator('[data-sidebar-collapsed]').waitFor({ state: 'attached' }) + await page.locator('[data-animating]').waitFor({ state: 'hidden' }) + await trace.evaluate((value) => { value.layouts.length = 0; value.documents.length = 0 }) + for (let cycle = 0; cycle < 3; cycle++) { + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') + await expect.poll(() => toggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true) + await toggle.click() + await expect.poll(() => toggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') + await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') + expect(await trace.evaluate(value => value.errors)).toEqual([]) + } + const layouts = await trace.evaluate(value => value.layouts) + expect(layouts.length).toBeGreaterThan(0) + const documentHeights = await trace.evaluate(value => value.documents) + expect(documentHeights.length).toBeGreaterThan(0) + expect(Math.max(...documentHeights) - Math.min(...documentHeights)).toBeLessThanOrEqual(1) + const heights = layouts.map(layout => layout.height) + expect(Math.max(...heights) - Math.min(...heights), JSON.stringify(layouts)).toBeLessThanOrEqual(1) + } + } finally { + await trace.evaluate((value) => { value.stop() }) + await trace.dispose() + } + await writeFile(state, JSON.stringify({ accessibility: 'unavailable', screenRecording: false })) + await page.evaluate(() => { window.dispatchEvent(new Event('focus')) }) + const diagnostic = panel.getByRole('alert') + await diagnostic.waitFor() + const diagnosticText = await diagnostic.innerText() + const height = await panel.evaluate(element => element.getBoundingClientRect().height) + const rowToggle = plugins.getByRole('switch', { name: 'Enable component @deepseek-ai/dsh-experimental-cua-macos', exact: true }) + const release = Promise.withResolvers() + const changeRow = scaffold.ctx.pluginManager.setPluginEnabled.bind(scaffold.ctx.pluginManager) + let changing: ReturnType | undefined + const hold = vi.spyOn(scaffold.ctx.pluginManager, 'setPluginEnabled').mockImplementationOnce((entryId, enabled) => { + changing = release.promise.then(() => changeRow(entryId, enabled)) + return changing + }) + try { + await rowToggle.click() + await expect.poll(() => hold.mock.calls.length, { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(1) + await expect.poll(() => panel.getAttribute('aria-busy')).toBe('true') + expect(await diagnostic.innerText()).toBe(diagnosticText) + expect(await diagnostic.getByRole('button', { name: 'Retry', exact: true }).isEnabled()).toBe(false) + expect(await panel.evaluate(element => element.getBoundingClientRect().height)).toBe(height) + } finally { + release.resolve(undefined) + hold.mockRestore() + await changing + } + await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') + await expect.poll(() => rowToggle.isEnabled(), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe(true) + await writeFile(state, JSON.stringify({ accessibility: false, screenRecording: false, launchApplication })) + await rowToggle.click() + await expect.poll(() => rowToggle.getAttribute('aria-checked'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('true') + await expect.poll(() => panel.getAttribute('aria-busy'), { timeout: PLUGIN_TOGGLE_SETTLE_MS }).toBe('false') + await diagnostic.waitFor({ state: 'hidden' }) expect(tripwire.pageErrors).toEqual([]) -}, 60_000) +}) diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index f50238cc81..52a6f4effb 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -1,5 +1,6 @@ - button "返回 实验性插件": 实验性插件 - button "卸载 思考过程机器翻译" [disabled]: 卸载 +- status - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] - text: v{{version}} 实验性 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index 223bcb3571..9e63c22442 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -1,5 +1,6 @@ - button "返回 实验性插件": 实验性插件 - button "卸载 思考过程机器翻译" [disabled]: 卸载 +- status - switch "启用 思考过程机器翻译" [checked] - heading "思考过程机器翻译" [level=3] - text: v{{version}} 实验性 diff --git a/apps/web/tests/expected/cua-permissions/enabling.expected.md b/apps/web/tests/expected/cua-permissions/enabling.expected.md new file mode 100644 index 0000000000..b0e8e66529 --- /dev/null +++ b/apps/web/tests/expected/cua-permissions/enabling.expected.md @@ -0,0 +1,5 @@ +- listitem: + - button "查看 Computer Use": Computer Use + - text: 实验性 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 + - status: 正在应用 Computer Use 的更改 + - switch "启用 Computer Use" [disabled] diff --git a/apps/web/tests/expected/cua-permissions/missing.expected.md b/apps/web/tests/expected/cua-permissions/missing.expected.md index 4255b31e9f..8dfba492a2 100644 --- a/apps/web/tests/expected/cua-permissions/missing.expected.md +++ b/apps/web/tests/expected/cua-permissions/missing.expected.md @@ -16,7 +16,7 @@ - strong: Screen Recording - paragraph: In System Settings, allow Visual Studio Code.app to capture application windows. - button "Request permission" - - paragraph: Enable the application in System Settings. Follow the macOS prompt if it names another application. + - paragraph: Manage permissions in System Settings for the application named by macOS. - region "Manage applications Computer Use can access": - heading "Manage applications Computer Use can access" [level=3] - heading "Always-allowed applications" [level=4] diff --git a/apps/web/tests/expected/plugin-config/bundle.expected.md b/apps/web/tests/expected/plugin-config/bundle.expected.md index 2e90c68ac0..3137d5400c 100644 --- a/apps/web/tests/expected/plugin-config/bundle.expected.md +++ b/apps/web/tests/expected/plugin-config/bundle.expected.md @@ -1,6 +1,7 @@ - button "返回插件": 插件 - button "夹具操作" - button "卸载 @fixture/live-client": 卸载 +- status - switch "启用 @fixture/live-client" [checked] - heading "@fixture/live-client" [level=3] - text: v0.0.1 夹具标签 diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 37d3a232b4..53c7ff8b5a 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -25,24 +25,30 @@ - listitem: - button "查看 智能体团队": 智能体团队 - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - status - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - status - switch "启用 语音输入" - listitem: - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - status - switch "启用 自动授权审查" - listitem: - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - status - switch "启用 开发者工具" - heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/live-client": "@fixture/live-client" + - status - switch "启用 @fixture/live-client" - listitem: - button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults + - status - switch "启用 dsh-web-scaffold-defaults" [checked] diff --git a/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md index b56769d22f..3da081d958 100644 --- a/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md +++ b/apps/web/tests/expected/plugin-install-cancel/highlight.expected.md @@ -1,5 +1,6 @@ - listitem: - button "查看 slow-package": slow-package + - status - switch "启用 slow-package" [checked] [ diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md index d00afb3ed1..11e0f00c13 100644 --- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -1,5 +1,6 @@ - button "Back to plugins": Plugins - button "Uninstall @fixture/bundle": Uninstall +- status - switch "Enable @fixture/bundle" - heading "@fixture/bundle" [level=3] - text: v0.0.1 diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md index 067989b093..d6d0dcd0be 100644 --- a/apps/web/tests/expected/plugin-manager/exports.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -1,5 +1,6 @@ - button "返回插件": 插件 - button "卸载 @fixture/bundle": 卸载 +- status - switch "启用 @fixture/bundle" - heading "@fixture/bundle" [level=3] - text: v0.0.1 diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index eab9dd48e1..2a8464f596 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -25,25 +25,31 @@ - listitem: - button "查看 智能体团队": 智能体团队 - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - status - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - status - switch "启用 语音输入" - listitem: - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - status - switch "启用 自动授权审查" - listitem: - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - status - switch "启用 开发者工具" - heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/bundle": "@fixture/bundle" - text: Registry description for the fixture bundle. + - status - switch "启用 @fixture/bundle" [checked] - listitem: - button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults + - status - switch "启用 dsh-web-scaffold-defaults" [checked] diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index f3dd4a7bc6..3d5cb8dfa7 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -25,25 +25,31 @@ - listitem: - button "查看 智能体团队": 智能体团队 - text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。 + - status - switch "启用 智能体团队" - listitem: - button "查看 语音输入": 语音输入 - text: 实验性 在本机使用 SenseVoice 转写录音,首次使用需安装依赖。 + - status - switch "启用 语音输入" - listitem: - button "查看 自动授权审查": 自动授权审查 - text: 实验性 提供自动审查权限模式,由模型在每次工具调用前判断是否授权。 + - status - switch "启用 自动授权审查" - listitem: - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 + - status - switch "启用 开发者工具" - heading "已安装插件" [level=3] - list: - listitem: - button "查看 @fixture/bundle": "@fixture/bundle" - text: Registry description for the fixture bundle. + - status - switch "启用 @fixture/bundle" - listitem: - button "查看 dsh-web-scaffold-defaults": dsh-web-scaffold-defaults + - status - switch "启用 dsh-web-scaffold-defaults" [checked] diff --git a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md index 268ad18e27..5d275c2323 100644 --- a/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md +++ b/apps/web/tests/expected/plugin-manager/missing-bundle.expected.md @@ -1,5 +1,6 @@ - button "返回插件": 插件 - button "卸载 @fixture/missing-bundle": 卸载 +- status - switch "启用 @fixture/missing-bundle" [checked] - heading "@fixture/missing-bundle" [level=3] - text: 异常 未安装 diff --git a/apps/web/tests/expected/plugin-manager/preview.expected.md b/apps/web/tests/expected/plugin-manager/preview.expected.md index 9e44db86f7..d28b7ebbfe 100644 --- a/apps/web/tests/expected/plugin-manager/preview.expected.md +++ b/apps/web/tests/expected/plugin-manager/preview.expected.md @@ -1,4 +1,5 @@ - button "返回 实验性插件": 实验性插件 +- status - switch "启用 DSH 徽章技能" - heading "DSH 徽章技能" [level=3] - text: v{{version}} 实验性 diff --git a/apps/web/tests/expected/plugin-manager/touch-tooltips.expected.md b/apps/web/tests/expected/plugin-manager/touch-tooltips.expected.md new file mode 100644 index 0000000000..4df272e593 --- /dev/null +++ b/apps/web/tests/expected/plugin-manager/touch-tooltips.expected.md @@ -0,0 +1,8 @@ +# Touch tooltips + +Touch refresh: one refresh request; no tooltip +Touch explanation: visible within the 390px viewport +- tooltip "在这里配置官方插件,安装和管理其他插件。内置插件列表及运行状态可在「设置 → 内置插件」中查看" +Explanation closes on another tap or an outside touch +Keyboard focus after touch: refresh tooltip visible +Mouse hover after touch: refresh tooltip visible diff --git a/apps/web/tests/expected/tooltip-touch/copy.expected.md b/apps/web/tests/expected/tooltip-touch/copy.expected.md new file mode 100644 index 0000000000..ea8faf5c86 --- /dev/null +++ b/apps/web/tests/expected/tooltip-touch/copy.expected.md @@ -0,0 +1,5 @@ +# Touch copy feedback + +Copy succeeds and the button returns from Copied to Copy. +Tooltips during copy feedback: 0 +Tooltips after emulated mouse entry: 0 diff --git a/apps/web/tests/hover-input.e2e.ts b/apps/web/tests/hover-input.e2e.ts new file mode 100644 index 0000000000..cae5220a89 --- /dev/null +++ b/apps/web/tests/hover-input.e2e.ts @@ -0,0 +1,242 @@ +/** Pointer, keyboard, and resize behavior in the assembled client. */ +import { fileURLToPath } from 'node:url' +import { chromium, type Locator } from 'playwright' +import { expect, it, onTestFailed, onTestFinished } from 'vitest' +import { launchWebScaffold, seedSession, watchConsole } from './scaffold.ts' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' +import { connectFreshWorkspace, saveFailureShot } from './support.ts' + +it.each([false, true])('shows the new-session shortcut on keyboard focus with touch=%s', async (hasTouch) => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const context = await browser.newContext({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' }) + await context.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) + const page = await context.newPage() + onTestFailed(() => saveFailureShot(page, `new-session-focus-${String(hasTouch)}`)) + await page.goto(scaffold.authenticatedUrl) + const button = page.getByRole('button', { name: 'New session', exact: true }).filter({ has: page.locator('kbd') }) + await button.waitFor() + const shortcut = button.locator('[class*="newSessionShortcut"]') + const mask = button.locator('[class*="newSessionLabelMask"]') + const opacity = () => shortcut.evaluate(element => getComputedStyle(element).opacity) + const maskImage = () => mask.evaluate(element => getComputedStyle(element).maskImage) + expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch) + expect(await opacity()).toBe('0') + expect(await maskImage()).toBe('none') + await button.focus() + await page.keyboard.press('Shift+Tab') + await page.keyboard.press('Tab') + expect(await button.evaluate(element => element === document.activeElement && element.matches(':focus-visible'))).toBe(true) + expect(await opacity()).toBe('1') + expect(await maskImage()).not.toBe('none') + await page.keyboard.press('Tab') + expect(await opacity()).toBe('0') + expect(await maskImage()).toBe('none') + if (hasTouch) { + await button.tap() + expect(await button.evaluate(element => element.matches(':focus-visible'))).toBe(false) + expect(await opacity()).toBe('0') + expect(await maskImage()).toBe('none') + } else { + await button.hover() + expect(await opacity()).toBe('1') + expect(await maskImage()).not.toBe('none') + } +}) + +it.each([false, true])('keeps workspace and archived search actions reachable with touch=%s', async (hasTouch) => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const fixture = createChatScrollFixture({ markerPrefix: 'ROW_ACTIONS', title: 'Archived row actions', turns: 1 }) + const id = await seedSession(scaffold, fixture.log, 'row-actions') + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const page = await browser.newPage({ viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US' }) + await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) }) + onTestFailed(() => saveFailureShot(page, `row-actions-${String(hasTouch)}`)) + const activate = async (target: Locator) => { + if (hasTouch) await target.tap() + else await target.click() + } + const openFixture = async () => { + await activate(page.getByRole('button', { name: 'Search sessions', exact: true })) + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + await activate(page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')) + await expect.poll(() => row.getAttribute('aria-selected')).toBe('true') + } + const row = page.locator(`[data-row-key="session:${id}"]`) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + await openFixture() + await activate(page.getByRole('button', { name: 'View options', exact: true })) + await activate(page.getByRole('menuitem', { name: 'WorkSpace', exact: true })) + const menu = page.getByRole('button', { name: 'Workspace actions for workspace', exact: true, includeHidden: true }) + const project = page.locator('[data-row-key^="workspace:"]').filter({ has: menu }) + await page.mouse.move(0, 0) + expect(await page.evaluate(() => matchMedia('(hover: none)').matches)).toBe(hasTouch) + expect(await menu.isVisible()).toBe(hasTouch) + if (!hasTouch) await project.hover() + await activate(menu) + await page.getByRole('menuitem', { name: 'Rename', exact: true }).waitFor() + await page.keyboard.press('Escape') + if (!hasTouch) await project.hover() + await activate(page.getByRole('button', { name: 'New session in workspace', exact: true })) + await expect.poll(() => row.getAttribute('aria-selected')).not.toBe('true') + expect(await project.getAttribute('aria-expanded')).toBe('true') + await openFixture() + if (!hasTouch) await row.hover() + const actions = row.getByRole('button', { name: /^Session actions for / }) + await activate(actions) + const archive = page.getByRole('menuitem', { name: 'Archive session', exact: true }) + const shortcut = archive.locator('[aria-hidden="true"]').filter({ has: page.locator('kbd') }) + await archive.waitFor() + expect(await shortcut.count()).toBe(1) + expect(await page.evaluate(() => matchMedia('(pointer: coarse)').matches)).toBe(hasTouch) + expect(await shortcut.isVisible()).toBe(!hasTouch) + for (const key of ['ArrowDown', 'Tab']) { + await page.keyboard.press(key) + expect(await page.locator('html').getAttribute('data-input-modality')).toBe('keyboard') + expect(await page.getByRole('menu').evaluate(menu => menu.contains(document.activeElement))).toBe(true) + expect(await shortcut.isVisible()).toBe(true) + if (hasTouch) { + await page.setViewportSize({ width: 390, height: 900 }) + await expect.poll(() => shortcut.evaluate((element) => { + const bounds = element.getBoundingClientRect() + return bounds.left >= 0 && bounds.right <= window.innerWidth + })).toBe(true) + await page.setViewportSize({ width: 1280, height: 900 }) + } + await page.keyboard.press('Escape') + if (!hasTouch) await row.hover() + await activate(actions) + await archive.waitFor() + expect(await shortcut.isVisible()).toBe(!hasTouch) + } + await activate(archive) + await row.waitFor({ state: 'detached' }) + await activate(page.getByRole('button', { name: 'View options', exact: true })) + await activate(page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true })) + await activate(page.getByRole('button', { name: 'Search sessions', exact: true })) + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await result.waitFor() + const restore = result.getByRole('button', { name: 'Unarchive session', exact: true, includeHidden: true }) + expect(await restore.isVisible()).toBe(hasTouch) + if (!hasTouch) await result.hover() + await activate(restore) + await expect.poll(() => scaffold.ctx.workspaceRegistry.archivedSessionIds.includes(id)).toBe(false) + await restore.waitFor({ state: 'detached' }) + await activate(result) + await expect.poll(() => row.getAttribute('aria-selected')).toBe('true') +}) + +it.each([false, true])('keeps actions and focus usable with touch=%s across layout widths', async (hasTouch) => { + const scaffold = await launchWebScaffold() + onTestFinished(() => scaffold.close()) + const fixture = createChatScrollFixture({ markerPrefix: 'HOVER', title: 'Hover input', turns: 1 }) + const id = await seedSession(scaffold, fixture.log, 'hover-input') + const browser = await chromium.launch() + onTestFinished(() => browser.close()) + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, hasTouch, locale: 'en-US', timezoneId: 'Asia/Shanghai', + }) + const page = await context.newPage() + const consoleWatch = watchConsole(page) + onTestFailed(() => saveFailureShot(page, `hover-input-${String(hasTouch)}`)) + await page.goto(scaffold.authenticatedUrl) + await connectFreshWorkspace(page, scaffold.workspaceCwd) + expect(await page.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(!hasTouch) + + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count()).toBe(1) + await result.click() + await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor() + const row = page.locator(`[data-row-key="session:${id}"]`) + await row.waitFor({ state: 'visible' }) + expect(await row.getAttribute('aria-selected')).toBe('true') + const copy = page.locator('[data-clock="end"] > button').first() + const background = () => copy.evaluate(element => getComputedStyle(element).backgroundColor) + await page.mouse.move(0, 0) + const resting = await background() + if (hasTouch) { + await copy.tap() + expect(await background()).toBe(resting) + expect(await row.getAttribute('aria-selected')).toBe('true') + expect(await page.getByRole('tooltip').count()).toBe(0) + } else { + await copy.hover() + await expect.poll(background).not.toBe(resting) + await page.mouse.move(0, 0) + await expect.poll(background).toBe(resting) + } + + await page.keyboard.press('Tab') + await copy.focus() + expect(await copy.evaluate(element => element.matches(':focus-visible'))).toBe(true) + await row.focus() + expect(await row.evaluate(element => document.activeElement === element)).toBe(true) + await page.keyboard.press('Enter') + expect(await row.getAttribute('aria-selected')).toBe('true') + + const image = fileURLToPath(new URL('../../../snapshots/session/read-image/workspace/red.png', import.meta.url)) + await page.locator('input[type="file"]').setInputFiles(image) + const remove = page.getByRole('button', { name: 'Remove image red.png', exact: true }) + await remove.waitFor({ state: 'visible' }) + if (hasTouch) { + expect(await remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await remove.tap() + } else { + await remove.focus() + await expect.poll(() => remove.evaluate(element => getComputedStyle(element).opacity)).toBe('1') + await page.keyboard.press('Enter') + } + await remove.waitFor({ state: 'detached' }) + + const input = page.locator('[data-composer-input]') + await input.fill('keep this draft') + await input.press('Home') + await input.press('Shift+End') + const element = await input.elementHandle() + const selection = () => input.evaluate(node => ({ + focused: document.activeElement === node, + text: node.textContent, + selected: window.getSelection()?.toString(), + })) + const before = await selection() + expect(before.focused).toBe(true) + for (const width of [390, 768, 1280]) { + await page.setViewportSize({ width, height: 900 }) + expect(await element!.evaluate(node => node.isConnected)).toBe(true) + expect(await selection()).toEqual(before) + } + + await page.getByRole('button', { name: 'Open right sidebar', exact: true }).click() + const panel = page.locator('[data-sidebar-right-panel][data-sidebar-right-open]') + await panel.getByRole('button', { name: 'Fullscreen', exact: true }).click() + await panel.locator('[data-dockkit-split-button]').click() + await expect.poll(() => panel.locator('[data-dockkit-pane]').count()).toBe(2) + await page.mouse.move(0, 0) + await page.keyboard.press('Escape') + await page.getByRole('tooltip').waitFor({ state: 'detached' }) + const blockedSplit = panel.locator('[data-dockkit-split-button]').last() + expect(await blockedSplit.isDisabled()).toBe(true) + const hit = await blockedSplit.evaluate((button) => { + const bounds = button.getBoundingClientRect() + const x = bounds.x + bounds.width / 2 + const y = bounds.y + bounds.height / 2 + const target = document.elementFromPoint(x, y) + return { x, y, wrapperReceivesInput: target === button.parentElement, cursor: target && getComputedStyle(target).cursor } + }) + expect(hit.wrapperReceivesInput).toBe(true) + expect(['auto', 'default']).toContain(hit.cursor) + // Coordinate input exercises CSS hit testing over the disabled button itself. + if (hasTouch) await page.touchscreen.tap(hit.x, hit.y) + else await page.mouse.click(hit.x, hit.y) + await page.getByRole('tooltip').filter({ hasText: 'Two panes is the limit' }).waitFor() + expect(await panel.locator('[data-dockkit-pane]').count()).toBe(2) + expect(consoleWatch.pageErrors).toEqual([]) +}) diff --git a/apps/web/tests/markdown-wide-table.e2e.ts b/apps/web/tests/markdown-wide-table.e2e.ts index 5cfe47a268..bdd7c5da6f 100644 --- a/apps/web/tests/markdown-wide-table.e2e.ts +++ b/apps/web/tests/markdown-wide-table.e2e.ts @@ -20,8 +20,8 @@ // NO_ADAPTER. import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { chromium, webkit } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished } from 'vitest' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { SESSION_FORMAT_VERSION, @@ -374,6 +374,44 @@ describe('web e2e: markdown tables fill the column, wide ones break out and scro expect(tripwire.pageErrors).toEqual([]) }, 120_000) + it.each(['chromium', 'webkit'] as const)('keeps wide tables horizontally scrollable without hover in %s', async (engine) => { + const touchBrowser = await ({ chromium, webkit })[engine].launch() + onTestFinished(() => touchBrowser.close()) + // Mobile WebKit has no wheel driver; touch emulation also disables hover in its desktop viewport. + const context = await touchBrowser.newContext({ + viewport: { width: 390, height: 844 }, hasTouch: true, isMobile: engine === 'chromium', locale: 'en-US', + }) + const touchPage = await context.newPage() + const touchConsole = watchConsole(touchPage) + onTestFailed(() => saveFailureShot(touchPage, `markdown-wide-table-touch-${engine}`)) + await touchPage.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + const group = touchPage.getByRole('treeitem').first() + await touchPage.getByRole('button', { name: 'Open sidebar', exact: true }).first().click() + await group.click() + await touchPage.getByRole('treeitem').filter({ hasText: 'Markdown wide tables' }).click() + await touchPage.getByText(TAIL_MARKER, { exact: true }).waitFor() + expect(await touchPage.evaluate(() => matchMedia('(hover: hover)').matches)).toBe(false) + const wide = touchPage.locator('[class*="tableScroll"]', { hasText: WIDE_MARKER }) + await wide.scrollIntoViewIfNeeded() + expect(await wide.evaluate(element => ({ + overflow: getComputedStyle(element).overflowX, + overflowing: element.scrollWidth > element.clientWidth, + focused: element.matches(':focus-visible'), + }))).toEqual({ overflow: 'auto', overflowing: true, focused: false }) + const box = await wide.boundingBox() + if (box === null) throw new Error('Wide table is not visible') + // A wheel input must scroll the native overflow container without hover or focus. + // Assigning scrollLeft would also work with overflow:hidden and miss this regression. + await touchPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2) + await touchPage.mouse.wheel(5_000, 0) + await expect.poll(() => wide.evaluate(element => element.scrollLeft)).toBeGreaterThan(0) + await expect.poll(() => wide.evaluate((element) => { + const last = element.querySelector('thead th:last-child')! + return last.getBoundingClientRect().right <= element.getBoundingClientRect().right + 1 + })).toBe(true) + expect(touchConsole.pageErrors).toEqual([]) + }) + it('reveals the wide table scrollbar on hover only', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-wide-table-scrollbar')) await sweep() diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index 4edd06e075..681366a3e6 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -298,7 +298,7 @@ describe('web e2e: plugin manager', () => { width: node.getBoundingClientRect().width, height: node.getBoundingClientRect().height, }))) - expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 36, height: 20 }))) + expect(blankActions).toEqual(Array.from({ length: 4 }, () => ({ children: 0, background: 'rgba(0, 0, 0, 0)', width: 58, height: 20 }))) if (shots !== undefined) { for (const colorScheme of ['light', 'dark'] as const) { await probe.emulateMedia({ colorScheme }) @@ -488,6 +488,88 @@ describe('web e2e: plugin manager', () => { } }) + it('keeps touch actions direct before keyboard focus and mouse hover', async () => { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, locale: ZH_BROWSER_LOCALE, + }) + try { + const probe = await context.newPage() + const input = await context.newCDPSession(probe) + // Native touch input preserves desktop hover capability for mixed-input devices. + const tap = async (target: Locator): Promise => { + await target.scrollIntoViewIfNeeded() + const bounds = await target.boundingBox() + if (bounds === null) throw new Error('Touch target is not visible') + await input.send('Input.dispatchTouchEvent', { + type: 'touchStart', touchPoints: [{ x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 }], + }) + await input.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] }) + } + const consoleWatch = watchConsole(probe) + onTestFailed(() => saveFailureShot(probe, 'web-e2e-touch-tooltips')) + await probe.clock.install() + await probe.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await probe.getByRole('button', { name: '插件', exact: true }).click() + const panel = probe.locator('[data-plugin-panel]') + const refresh = panel.getByRole('button', { name: '刷新', exact: true }) + const info = panel.getByRole('button', { name: '插件说明', exact: true }) + await expect.poll(() => refresh.isEnabled()).toBe(true) + await probe.setViewportSize({ width: 390, height: 844 }) + await probe.evaluate(() => document.fonts.ready) + const calls = vi.spyOn(scaffold.ctx.pluginManager, 'listBundles') + try { + await tap(refresh) + await expect.poll(() => calls.mock.calls.length).toBe(1) + await expect.poll(() => refresh.isEnabled()).toBe(true) + } finally { + calls.mockRestore() + } + await probe.clock.pauseAt(await probe.evaluate(() => Date.now() + 1000)) + await probe.clock.runFor(500) + expect(await probe.getByRole('tooltip').count()).toBe(0) + const facts = ['Touch refresh: one refresh request; no tooltip'] + + await tap(info) + const help = probe.getByRole('tooltip') + await help.waitFor() + const bounds = await help.boundingBox() + if (bounds === null) throw new Error('Tapped plugin explanation is not visible') + expect(bounds.x).toBeGreaterThanOrEqual(12) + expect(bounds.x + bounds.width).toBeLessThanOrEqual(390 - 12) + expect(bounds.y).toBeGreaterThanOrEqual(12) + expect(bounds.y + bounds.height).toBeLessThanOrEqual(844 - 12) + facts.push('Touch explanation: visible within the 390px viewport', await help.ariaSnapshot()) + await tap(info) + expect(await help.count()).toBe(0) + await tap(info) + await help.waitFor() + await tap(panel.getByRole('heading', { name: '插件', exact: true })) + expect(await help.count()).toBe(0) + facts.push('Explanation closes on another tap or an outside touch') + + await probe.keyboard.press('Tab') + await refresh.focus() + await probe.clock.runFor(500) + await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor() + facts.push('Keyboard focus after touch: refresh tooltip visible') + await probe.keyboard.press('Tab') + expect(await probe.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(true) + await refresh.hover() + await probe.clock.runFor(500) + await probe.getByRole('tooltip').filter({ hasText: '刷新' }).waitFor() + facts.push('Mouse hover after touch: refresh tooltip visible') + await probe.mouse.move(0, 843) + expect(await help.count()).toBe(0) + await input.detach() + await probe.clock.resume() + await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'touch-tooltips.expected.md'), + ['# Touch tooltips', '', ...facts].join('\n'), MODE) + expect(consoleWatch.pageErrors).toEqual([]) + } finally { + await context.close() + } + }) + it('starts with an unavailable selected bundle and lets the user clear its error', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-plugin-manager-missing-bundle')) const panel = await openPluginsPanel() @@ -1139,7 +1221,7 @@ describe('web e2e: plugin manager', () => { expect(tripwire.warnings).toEqual([]) await assertFixtureInventory(SNAPSHOT_DIR, [ 'manager.expected.md', 'live-enabled.expected.md', 'missing-bundle.expected.md', 'exports.expected.md', 'exports-en.expected.md', 'icons.expected.md', 'loading.expected.md', 'refresh.expected.md', - 'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', + 'ime-enter.expected.md', 'header-responsive.expected.md', 'preview.expected.md', 'touch-tooltips.expected.md', ]) }) }) diff --git a/apps/web/tests/present.e2e.ts b/apps/web/tests/present.e2e.ts index 59c8840510..6a9a790de7 100644 --- a/apps/web/tests/present.e2e.ts +++ b/apps/web/tests/present.e2e.ts @@ -175,6 +175,31 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con expect(await opened()).toHaveLength(count + 1) expect((await opened()).at(-1)).toEqual({ action: 'open', path: await realpath(join(cwd, name)), content: bytes }) } + const success = row.locator('[data-presented-file]').filter({ hasText: '说明.txt' }).locator('[data-success]') + await success.waitFor() + const animation = await success.evaluate((element) => { + const style = getComputedStyle(element) + const fade = element.getAnimations()[0] + if (fade === undefined) throw new Error('Successful file feedback has no fade animation') + // Seek the CSS animation so the 200 ms fade does not race the assertion. + fade.pause() + fade.currentTime = 5100 + return { + delay: style.animationDelay, duration: style.animationDuration, + fill: style.animationFillMode, opacity: Number(getComputedStyle(element).opacity), + } + }) + expect(animation.delay).toBe('5s') + expect(animation.duration).toBe('0.2s') + expect(animation.fill).toBe('forwards') + expect(animation.opacity).toBeGreaterThan(0) + expect(animation.opacity).toBeLessThan(1) + try { + await page.emulateMedia({ reducedMotion: 'reduce' }) + expect(await success.evaluate(element => getComputedStyle(element).animationName)).toBe('none') + } finally { + await page.emulateMedia({ reducedMotion: 'no-preference' }) + } } expect(downloads).toEqual([]) const response = await page.request.get(new URL(`/api/session.export?sessionId=${sessionId}`, scaffold.authenticatedUrl).href) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index be06ec07f0..27ae202a86 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -47,12 +47,13 @@ const AUTO_PARENT_ADJUST = 'AUTO_PARENT_ADJUST' /** Identify the one-shot Auto Review request and check its request-only outer input. */ function isAutoReviewRequest(options: GenerateOptions): boolean { if (options.system?.startsWith('REVIEW_POLICY\n') !== true) return false - expect(options.messages).toHaveLength(1) - const message = options.messages[0] - expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] }) - expect(message?.content).toHaveLength(1) - expect(message).not.toHaveProperty('id') - expect(message).not.toHaveProperty('source') + expect(options.messages.length).toBeGreaterThanOrEqual(2) + for (const message of options.messages) { + expect(message).toMatchObject({ role: 'user', content: [{ type: 'text' }] }) + expect(message.content).toHaveLength(1) + expect(message).not.toHaveProperty('id') + expect(message).not.toHaveProperty('source') + } return true } @@ -150,21 +151,19 @@ interface ChildScriptState { phase: number } -/** Parse the fixed review request sections emitted by the production plugin. */ +/** Parse the history items and pending action emitted by the production plugin. */ function childReviewSections(options: GenerateOptions): Record { - const block = options.messages[0]?.content[0] - if (block?.type !== 'text') throw new Error('shipped child review request has no text body') - const labels = ['ENVIRONMENT', 'PROJECT_INSTRUCTIONS', 'FILTERED_HISTORY', 'PENDING_ACTION'] as const - const sections: Record = {} - for (const [index, label] of labels.entries()) { - const prefix = `${label}\n` - const start = block.text.indexOf(prefix) - if (start < 0) throw new Error(`shipped child review request is missing ${label}`) - const next = labels[index + 1] - const end = next === undefined ? block.text.length : block.text.indexOf(`\n\n${next}\n`, start) - sections[label] = JSON.parse(block.text.slice(start + prefix.length, end)) as unknown + const items = options.messages.slice(1).map((message) => { + const block = message.content[0] + if (block?.type !== 'text') throw new Error('shipped child review message has no text body') + return JSON.parse(block.text) as Record + }) + const call = items.at(-1) + if (call?.kind !== 'tool-call') throw new Error('shipped child review request does not end with its pending call') + return { + FILTERED_HISTORY: items.slice(0, -1).filter(item => item.kind !== 'tool-schema'), + PENDING_ACTION: { name: call.name, arguments: JSON.parse(call.arguments as string) as Record }, } - return sections } /** One native tool-call completion in the provider-neutral stream vocabulary. */ @@ -790,7 +789,8 @@ it('routes one browser-authored Auto request through the same model and asks the .filter(block => block.type === 'text') .map(block => block.text) .join('') ?? '' - expect(reviewInput).toContain('PENDING_ACTION') + expect(reviewInput).toContain('ENVIRONMENT') + expect(reviewInput).toContain('"kind": "tool-schema"') expect(reviewInput).toContain(requestId) expect(reviewInput).toContain(targetPath) expect(approvalReasons).toEqual([`Auto review denied tool "bash": ${AUTO_RAW_REASON}`]) diff --git a/apps/web/tests/sidebar-scrollbar.e2e.ts b/apps/web/tests/sidebar-scrollbar.e2e.ts index a45d2350a5..f15a893682 100644 --- a/apps/web/tests/sidebar-scrollbar.e2e.ts +++ b/apps/web/tests/sidebar-scrollbar.e2e.ts @@ -71,17 +71,22 @@ function measureList(page: Page): Promise { probe.remove() return value } - // Computed pseudo style folds in hover even at rest, so inspect the cascade. + // Computed pseudo style folds in hover even at rest, so inspect active CSS groups. // Cross-origin sheets may throw and cannot contain the app-owned rule. + const styleRules = (rules: CSSRuleList): CSSStyleRule[] => [...rules].flatMap((rule) => { + if (rule instanceof CSSStyleRule) return [rule] + if (rule instanceof CSSMediaRule && !matchMedia(rule.conditionText).matches) return [] + if (rule instanceof CSSSupportsRule && !CSS.supports(rule.conditionText)) return [] + return rule instanceof CSSGroupingRule ? styleRules(rule.cssRules) : [] + }) const hoverRules = [...document.styleSheets] .flatMap((sheet) => { try { - return [...sheet.cssRules] + return styleRules(sheet.cssRules) } catch { return [] } }) - .filter((rule): rule is CSSStyleRule => rule instanceof CSSStyleRule) .filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover') .map(rule => rule.style.getPropertyValue('background-color')) const style = getComputedStyle(list) diff --git a/apps/web/tests/turn-tail-actions.e2e.ts b/apps/web/tests/turn-tail-actions.e2e.ts index 4b422197b5..86fabe1acb 100644 --- a/apps/web/tests/turn-tail-actions.e2e.ts +++ b/apps/web/tests/turn-tail-actions.e2e.ts @@ -12,14 +12,15 @@ import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Locator, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' +import { chromium, devices, webkit } from 'playwright' +import { afterEach, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest' import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { acknowledgeReloadConnectionLoss, assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, - launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, recordFixture, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' +import { createChatScrollFixture } from './chat-scroll-fixture.ts' import { openSettings, connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, saveFailureShot, WEB_FIXTURE_TIME } from './support.ts' const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/turn-tail-actions', import.meta.url)) @@ -347,3 +348,49 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => { ) }) }) + +it('keeps copy tooltips hidden during a WebKit touch copy', async () => { + const scaffold = await launchWebScaffold({}) + const browserPromise = webkit.launch() + onTestFinished(async () => { + try { await (await browserPromise).close() } + finally { await scaffold.close() } + }) + const browser = await browserPromise + const context = await browser.newContext({ + ...devices['iPhone 13'], locale: 'en-US', viewport: { width: 1280, height: 900 }, + }) + const page = await context.newPage() + // LAN HTTP pages use the clipboard fallback on iOS. + await page.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { configurable: true, value: undefined }) + }) + const fixture = createChatScrollFixture({ markerPrefix: 'TOOLTIP_TOUCH', title: 'Touch tooltips', turns: 1 }) + await seedSession(scaffold, fixture.log, 'tooltip-touch-web-e2e') + await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) + await connectFreshWorkspace(page, scaffold.workspaceCwd, 'tooltip-touch') + await page.getByRole('button', { name: 'Search sessions' }).click() + await page.getByRole('textbox', { name: 'Search session names', exact: true }).fill(fixture.markers.user(1)) + const result = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem') + await expect.poll(() => result.count(), { timeout: 60_000 }).toBe(1) + await result.click() + await page.getByText(fixture.markers.assistant(1), { exact: false }).last().waitFor() + await page.setViewportSize({ width: 390, height: 844 }) + expect(await page.evaluate(() => matchMedia('(any-hover: hover)').matches)).toBe(false) + const copy = page.getByRole('button', { name: 'Copy', exact: true }).last() + await copy.tap() + await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count()).toBe(1) + const duringCopy = await page.getByRole('tooltip').count() + expect(duringCopy).toBe(0) + await expect.poll(() => page.getByRole('button', { name: 'Copied', exact: true }).count(), { timeout: 10_000 }).toBe(0) + // iOS may synthesize mouse entry when the checkmark is replaced by the copy icon. + await copy.dispatchEvent('pointerover', { pointerType: 'mouse', bubbles: true }) + const afterCopy = await page.getByRole('tooltip').count() + expect(afterCopy).toBe(0) + await compareOrRefreshGolden( + fileURLToPath(new URL('./expected/tooltip-touch/copy.expected.md', import.meta.url)), + ['# Touch copy feedback', '', 'Copy succeeds and the button returns from Copied to Copy.', + `Tooltips during copy feedback: ${duringCopy}`, `Tooltips after emulated mouse entry: ${afterCopy}`].join('\n'), + webSnapshotMode(), + ) +}) diff --git a/apps/web/tests/voice-setup.e2e.ts b/apps/web/tests/voice-setup.e2e.ts index 9a995e6fff..b592e9eb48 100644 --- a/apps/web/tests/voice-setup.e2e.ts +++ b/apps/web/tests/voice-setup.e2e.ts @@ -32,7 +32,7 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo await dialog.waitFor({ state: 'hidden' }) expect(await toggle.getAttribute('aria-checked')).toBe('true') const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]') - expect(await card.getByRole('status').count()).toBe(0) + expect(await card.getByRole('status').textContent()).toBe('') await toggle.click() await page.getByRole('switch', { name: 'Enable Voice input', exact: true, checked: false }).click() await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 9c74083325..6a03b44055 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -23,6 +23,7 @@ // this client-registered project must not also hold them (one program // cannot see both sides of the cordis Context merges). "exclude": [ + "tests/hover-input.e2e.ts", "tests/collapse-timing.e2e.ts", "tests/notice-wake-tail.e2e.ts", "tests/fold-blank-matrix.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index adcb970d6d..13b0d8b44f 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -18,11 +18,11 @@ en: b9eeb18645121a8b zh: 70d87a12c43e042a /deepseek-harness-architecture/core-packages: - en: f4dde8809a713bad - zh: ccbee814b4e8b4b7 + en: 20ef882e44b06a10 + zh: c10c3934ed5570e9 /deepseek-harness-architecture/events: - en: c00467ff3a210ef5 - zh: 7619d7cca723fa6b + en: e89802f066e4955d + zh: ea2fb4bef94e086d /deepseek-harness-architecture/turn-flow: en: e6402b28c0ed15fa zh: 6c07be9047b42056 diff --git a/docs/architecture.md b/docs/architecture.md index ede41eb827..2e33a1102b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -61,7 +61,6 @@ Electron starts the private Desktop Host in Electron Node mode. The Host invokes | [`core/tools`](subsystems/tools.md) | The scoped tool registry and guarded execution pipeline | `ctx.tools` | | [`core/agent`](subsystems/core.md) | The `Agent` interface, live registry, and `agent/*` events | `ctx.agents` | | [`core/agent-loop`](subsystems/core.md) | The default driver implementing that interface | `ctx.agentLoop` | -| [`core/agent-controller`](subsystems/core.md#messages-from-outside-an-agent) | Admission and delivery of outside messages | `ctx.agentController` | | [`core/scope`](subsystems/scope.md) | The per-agent scoped-registration primitive | library, no key | | [`llm/llm`](subsystems/llm-streaming.md) | Message and stream vocabulary plus the adapter seam | `ctx.llm` | @@ -71,7 +70,6 @@ Events are the extension points, and picking the right domain is the first decis - **Session events** are durable facts appended to the log and broadcast through `session/event`. Use one when the fact must survive a reload. - **Agent events** (`agent/*`) carry a live `Agent`: inbox, step, status, request, validation, continuation. Use one to observe or intercept work in flight. -- **Delivery admission** (`agent-controller/admission`) holds or refuses outside input before it enters the inbox. - **Capability events** attach policy and adapters to a seam (`fs/*`, `tools/*`, `telemetry/*`) without importing the loop. AgentLoop awaits serial `agent/created` before queued work and rolls back failed initialization. Unload retains resources through Agent teardown; [agent-loop](../packages/core/agent-loop/README.md#understand-the-implementation) defines ordering. diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 9ac9b293d2..274ff87ae6 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -61,7 +61,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 | [`core/tools`](subsystems/tools.zh.md) | 作用域化的工具注册表和带把关的执行流水线 | `ctx.tools` | | [`core/agent`](subsystems/core.zh.md) | `Agent` 接口、活跃 agent 注册表和 `agent/*` 事件 | `ctx.agents` | | [`core/agent-loop`](subsystems/core.zh.md) | 实现该接口的默认驱动器 | `ctx.agentLoop` | -| [`core/agent-controller`](subsystems/core.zh.md#messages-from-outside-an-agent) | 外部消息的准入与投递 | `ctx.agentController` | | [`core/scope`](subsystems/scope.zh.md) | 按 agent 划分作用域的注册原语 | 库,无 ctx 键 | | [`llm/llm`](subsystems/llm-streaming.zh.md) | 消息与流式词汇表,以及适配器 seam | `ctx.llm` | @@ -73,7 +72,6 @@ Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共 - **会话事件**是追加到日志并通过 `session/event` 广播的持久事实。当某个事实必须在重新加载后仍然存在时,使用它。 - **Agent 事件**(`agent/*`)携带活跃 `Agent`:inbox、步骤、状态、请求、验证、续跑。要观察或拦截进行中的工作时,使用它。 -- **投递准入**(`agent-controller/admission`)在外部输入进入收件箱之前暂留或拒绝它。 - **能力事件**无需导入循环即可向某个 seam(`fs/*`、`tools/*`、`telemetry/*`)附加策略和适配器。 AgentLoop 在启动已排队工作前等待串行 `agent/created`,初始化失败则回滚。卸载在 Agent teardown 期间保留所需资源;[agent-loop](../packages/core/agent-loop/README.zh.md#understand-the-implementation)定义顺序。 diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index b35db923e6..ae1d83cdf3 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 3e87315977a89b5a - zh: 626a75f3bebaa39f + en: 753b3da3abc6aeea + zh: aab02ec16c3f744b diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 7c66c89b46..f2b52ade47 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -191,8 +191,6 @@ flowchart LR pkg_skill_office["skill-office"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] - pkg_agent_controller["agent-controller"] - svc_agentController["ctx.agentController
Outside-message delivery"] svc_agentDefaultModel["ctx.agentDefaultModel
Default Agent model selection"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] @@ -300,7 +298,6 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_agent --> svc_agents - pkg_agent_controller --> svc_agentController pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets @@ -459,7 +456,6 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges - svc_agentController --> pkg_api_session_controller svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -690,7 +686,6 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | -| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone's behalf, one at a time per Agent. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | Stores tasks independently of Session activation and queues due messages in the original Session. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 8e649183bc..fede454e27 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -193,8 +193,6 @@ flowchart LR pkg_skill_office["skill-office"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] - pkg_agent_controller["agent-controller"] - svc_agentController["ctx.agentController
Outside-message delivery"] svc_agentDefaultModel["ctx.agentDefaultModel
Default Agent model selection"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_base["base"] @@ -302,7 +300,6 @@ flowchart LR svc_dynamicCordisRunner["ctx.dynamicCordisRunner
Dynamic Cordis package host runner"] svc_cordisInspect["ctx.cordisInspect
Dynamic Cordis inspect registry"] pkg_agent --> svc_agents - pkg_agent_controller --> svc_agentController pkg_agent_default_model --> svc_agentDefaultModel pkg_agent_loop --> svc_agentLoop pkg_agent_preset_registry --> svc_agentPresets @@ -461,7 +458,6 @@ flowchart LR pkg_working_directory --> svc_workingDirectory pkg_workspace --> svc_workspaceRegistry pkg_workspace_changes --> svc_workspaceChanges - svc_agentController --> pkg_api_session_controller svc_agentDefaultModel --> pkg_api_session_controller svc_agentDefaultModel --> pkg_headless svc_agentLoop --> pkg_base @@ -692,7 +688,6 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/experimental/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent`](../packages/subagent/subagent) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | -| `ctx.agentController` | `core` | [`agent-controller`](../packages/core/agent-controller) | - | [`api-session-controller`](../packages/api/session-controller) | - | 准备发送方代表他人送来的消息,经 agent-controller/admission 阶段接纳后放入收件箱,每个 Agent 逐条处理。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | | `ctx.schedule` | `core` | [`schedule`](../packages/schedule/schedule) | - | - | - | 独立于 Session 的加载状态存储任务,并将到期消息排入原 Session。 | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 2dfb05db51..6d960b604b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -225,7 +225,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` - `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) ```ts config-catalog @@ -4700,7 +4700,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | -| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | | `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index ad7cfa9baf..34b89fbfd4 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -227,7 +227,7 @@ export interface Config { ## `@deepseek-ai/dsh-api-session-controller` -- `inject`: `agentController` · `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` +- `inject`: `agentDefaultModel` · `agents` · `attachments` · `fileUploads` · `fs` · `llm` · `remoteAccess` · `sessions` · `sessionProjections` · `sessionQuery` · `typert` · `workspaceRegistry` - `source`: [`packages/api/session-controller/src/index.ts:93`](../packages/api/session-controller/src/index.ts) ```ts config-catalog @@ -4702,7 +4702,6 @@ export interface Config { | --- | --- | --- | | `@deepseek-ai/dsh-acp-app` | `cmdlineArgs` | [`packages/bundle/acp-app/src/index.ts`](../packages/bundle/acp-app/src/index.ts) | | `@deepseek-ai/dsh-agent` | `sessionProjections` | [`packages/core/agent/src/index.ts`](../packages/core/agent/src/index.ts) | -| `@deepseek-ai/dsh-agent-controller` | `agents` | [`packages/core/agent-controller/src/index.ts`](../packages/core/agent-controller/src/index.ts) | | `@deepseek-ai/dsh-api-account-controller` | `deepseekAccount` · `agents` | [`packages/api/account-controller/src/index.ts`](../packages/api/account-controller/src/index.ts) | | `@deepseek-ai/dsh-api-remote-access` | — | [`packages/api/remote-access/src/index.ts`](../packages/api/remote-access/src/index.ts) | | `@deepseek-ai/dsh-api-remotes` | `typertGateway` | [`packages/api/remotes/src/index.ts`](../packages/api/remotes/src/index.ts) | diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 904ebe2ac7..53495f8ac9 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,6 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -23,11 +22,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index a008173af6..0ce2068f13 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,6 @@ | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-controller/admission` | `waterfall` | [`packages/core/agent-controller/src/types.ts:79`](../packages/core/agent-controller/src/types.ts) | [`agent-controller`](../packages/core/agent-controller) (`waterfall`) | - | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:196`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | | `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:66`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | [`cua`](../packages/experimental/cua), `remotes` | | `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` | @@ -25,11 +24,11 @@ | `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) | | `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:381`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/experimental/hooks-claude-code), [`hooks-codex`](../packages/experimental/hooks-codex), [`workspace-changes`](../packages/deliverables/workspace-changes) | -| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:632`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:639`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:618`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | -| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:625`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:626`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:606`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:633`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | +| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:619`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` | | `app-boot/config-reload` | `emit` | [`packages/boot/app-boot/src/index.ts:54`](../packages/boot/app-boot/src/index.ts) | [`app-boot`](../packages/boot/app-boot) (`emit`) | [`settings`](../packages/settings/settings) | | `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:87`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` | | `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | - | diff --git a/docs/module-graph.md b/docs/module-graph.md index 9fec6a3bba..1566f893e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -41,7 +41,6 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] - pkg_agent_controller["agent-controller"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] pkg_agent_tool_presentation["agent-tool-presentation"] @@ -641,9 +640,6 @@ flowchart TD pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_session_projection pkg_llm_retry --> pkg_timeout - pkg_agent_controller --> pkg_agent - pkg_agent_controller --> pkg_llm - pkg_agent_controller --> pkg_scope pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_llm pkg_goal --> pkg_agent @@ -1275,7 +1271,6 @@ flowchart TD pkg_tool_subagent_control --> pkg_subagent pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_controller pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_preset_registry pkg_api_session_controller --> pkg_api_gateway @@ -1656,7 +1651,6 @@ flowchart TD | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | -| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | @@ -1775,7 +1769,7 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index f94966fab3..57e96d7a4f 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -43,7 +43,6 @@ flowchart TD end subgraph group_core["packages/core"] pkg_agent["agent"] - pkg_agent_controller["agent-controller"] pkg_agent_default_model["agent-default-model"] pkg_agent_loop["agent-loop"] pkg_agent_tool_presentation["agent-tool-presentation"] @@ -643,9 +642,6 @@ flowchart TD pkg_llm_retry --> pkg_session pkg_llm_retry --> pkg_session_projection pkg_llm_retry --> pkg_timeout - pkg_agent_controller --> pkg_agent - pkg_agent_controller --> pkg_llm - pkg_agent_controller --> pkg_scope pkg_agent_default_model --> pkg_agent pkg_agent_default_model --> pkg_llm pkg_goal --> pkg_agent @@ -1277,7 +1273,6 @@ flowchart TD pkg_tool_subagent_control --> pkg_subagent pkg_tool_subagent_control --> pkg_tools pkg_api_session_controller --> pkg_agent - pkg_api_session_controller --> pkg_agent_controller pkg_api_session_controller --> pkg_agent_default_model pkg_api_session_controller --> pkg_agent_preset_registry pkg_api_session_controller --> pkg_api_gateway @@ -1658,7 +1653,6 @@ flowchart TD | [`pwsh-local`](../packages/shell/pwsh-local) | `shell` | [`shell`](../packages/shell/shell), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`llm-deepseek-api-key`](../packages/llm/llm-deepseek-api-key) | `llm` | [`credentials`](../packages/credentials/credentials), [`launch-environment`](../packages/util/launch-environment), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`timeout`](../packages/util/timeout) | -| [`agent-controller`](../packages/core/agent-controller) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | | [`agent-default-model`](../packages/core/agent-default-model) | `core` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`typert-protocol`](../packages/typert/protocol) | | [`file-reference`](../packages/context/file-reference) | `context` | [`agent`](../packages/core/agent) | @@ -1777,7 +1771,7 @@ flowchart TD | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent) | | [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | -| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-controller`](../packages/core/agent-controller), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | +| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`credentials`](../packages/credentials/credentials), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`settings`](../packages/settings/settings), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`working-directory`](../packages/session/working-directory), [`workspace`](../packages/workspace/workspace) | | [`plugin-market`](../packages/boot/plugin-market) | `boot` | [`plugin-manager`](../packages/boot/plugin-manager), [`settings`](../packages/settings/settings), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`subagent`](../packages/subagent/subagent), [`user-approval`](../packages/interaction/user-approval) | | [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index 7aae4e7d05..ed3dfb8f6c 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -1347,8 +1347,8 @@ en: 2786fef560851bb4 zh: 5d787475356b2606 /session-persistence-event-catalog/resolved-persistence-types/string: - en: 324d7ebffee10aad - zh: 31f7b8a18571665a + en: 41c03b03d1bd5a44 + zh: 1b0e430af6c140b3 /session-persistence-event-catalog/resolved-persistence-types/string~2: en: 0089620bf4638263 zh: 1922bef1713094bb @@ -1833,8 +1833,8 @@ en: 809a23c25ded8497 zh: e9cd23e281f8d31f /session-persistence-event-catalog/resolved-persistence-types/kind-user: - en: db30e659a1cc4c06 - zh: 8585ea4c8178ab37 + en: 53b7abac679ee252 + zh: 63eb6a015986ef6d /session-persistence-event-catalog/resolved-persistence-types/kind-user-approval: en: a52d2dfbaf844127 zh: 4c5306ca40f20873 diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 056aa2e5f9..da02534d25 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -5583,7 +5583,7 @@ Array of `number`. SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -Sources: [`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7554,7 +7554,7 @@ Sources: [`packages/core/tools/src/index.ts:35`](../packages/core/tools/src/inde SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -Sources: [`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts) +Sources: [`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) | Property | Presence | Type | |---|---|---| diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 71fb808356..062a1e58b1 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -5585,7 +5585,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78` SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b` -来源:[`packages/api/session-controller/src/types.ts:412`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:406`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:21`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:21`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:33`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:9`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/webhook/src/brand.ts:12`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:6`](../packages/experimental/webhook/src/brand.ts) · [`packages/experimental/webhook/src/brand.ts:9`](../packages/experimental/webhook/src/brand.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/jobs/jobs/src/brand.ts:19`](../packages/jobs/jobs/src/brand.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:14`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts) `string` @@ -7556,7 +7556,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543` SHA-256: `6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def` -来源:[`packages/api/session-controller/src/types.ts:421`](../packages/api/session-controller/src/types.ts) +来源:[`packages/api/session-controller/src/types.ts:415`](../packages/api/session-controller/src/types.ts) | 属性 | 存在性 | 类型 | |---|---|---| diff --git a/docs/persistence-schema.json b/docs/persistence-schema.json index 01eaa1a4b6..d3bbdddd21 100644 --- a/docs/persistence-schema.json +++ b/docs/persistence-schema.json @@ -23574,7 +23574,7 @@ "packages/workflow/workflow/src/types.ts#WorkflowRunId" ], "sources": [ - "packages/api/session-controller/src/types.ts:412", + "packages/api/session-controller/src/types.ts:406", "packages/attachment/attachment/src/brand.ts:6", "packages/compaction/compaction/src/brand.ts:4", "packages/core/session/src/types.ts:21", @@ -24096,7 +24096,7 @@ "digest": "6dacb1584f2bfbe209dc6b6f578d51da6d55595f64d0e63c727c98f359278def", "names": [], "sources": [ - "packages/api/session-controller/src/types.ts:421" + "packages/api/session-controller/src/types.ts:415" ] }, { diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index 5ab6c342eb..4033bc6756 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -6,8 +6,8 @@ en: f6ae07d8902e8edf zh: c92fef531c6da331 /core/the-spine-package-by-package: - en: 60926dc99f41024d - zh: f40370b034f91463 + en: c90cb23528ddb6c8 + zh: 62724b7255b586b6 /core/creation-and-ownership: en: ab09a38b4e1f0883 zh: 81e2d14c4036ab6c @@ -16,10 +16,7 @@ zh: 25bc9cb60ee1520a /core/initiating-agent: en: 6c8c47ac78de2dde - zh: acde99c8808ea891 -/core/messages-from-outside-an-agent: - en: cf087fd9ff5edb87 - zh: c3bb56b17cd76c52 + zh: c9d527a25a85d1d9 /core/interception-decisions: en: 6fccceb0a5f1cad9 zh: f43c30c2386d8cc8 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index 4b7c0f3341..9ae060186b 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -15,10 +15,9 @@ A turn flows through the six packages in one loop: the driver in [`agent-loop`]( | `tools/` | The scoped tool registry and guarded execution pipeline (`ctx.tools`) | [tools.md](tools.md) | | `agent/` | The `Agent` interface, live registry, initiator scope, and `agent/*` event vocabulary (`ctx.agents`) | this page | | `agent-loop/` | The concrete driver implementing the public `Agent` contract (`ctx.agentLoop`) | this page | -| `agent-controller/` | Delivery of messages from outside an Agent through the `agent-controller/admission` stage (`ctx.agentController`) | this page | | `scope/` | The scoped-registration primitive the registries and loop build per-agent scoping on | [scope.md](scope.md) | -`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. `agent-controller` sits in front of the loop: a sender that brings a message on someone's behalf delivers it through `ctx.agentController` instead of appending to the inbox itself. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree. +`scope/` is the one non-service package: a dependency-free library (`createScope`/`scopeOf`/`scopeTarget`) that sits below `session/` and `system-prompt/` in the module graph precisely so they can consume it without a cycle. `agent-loop` is the one concrete implementation of the public `Agent` contract and lives here because it is the harness's default product loop; it runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent` — including when they need the initiating Agent — and never on `agent-loop` directly, so the loop stays swappable. [`dsh-base`](../../packages/bundle/base/README.md) is the default product composition, while [`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.md) declares a smaller standalone tree. ## Creation and ownership @@ -306,65 +305,6 @@ The [event taxonomy](../architecture.md#events) owns the `agent/*` lifecycle, ch The process-local initiator carried by `ctx.agents` is the exact `Agent` above, not a separate frame or copied identity. Ambient presence is neither liveness proof nor authorization; the [initiator-scope decision](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md) defines its lifetime and scope rules. -## Messages from outside an Agent - -A sender that brings a message on someone's behalf, such as the Web Session Remote, calls `ctx.agentController.deliver` from [`agent-controller`](../../packages/core/agent-controller/README.md) instead of `followup()` or `steer()`. The controller prepares the content in the Agent's delivery order, asks the `agent-controller/admission` waterfall for a decision, and appends an admitted message; messages the Agent's own machinery produces enter without the stage. The [delivery decision](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.md) explains why the stage lives outside `agent`. - -Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts) - -```ts type-equiv -/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */ -type DeliveryMode = 'followup' | 'steer' -``` - -```ts type-equiv -/** One message a sender delivers on someone's behalf. */ -interface DeliveryRequest { - /** Where the message enters once it is admitted. */ - readonly mode: DeliveryMode - /** Durable attribution recorded on the message. */ - readonly source: MessageSource - /** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */ - readonly signal: AbortSignal - /** - * Turn the sender's content into durable message content, such as by - * storing attachments. It runs in the Agent's delivery order, after the - * earlier messages to the same Agent settled; its failure reaches the - * sender unchanged. - * @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle. - * @returns the content of the message. - */ - readonly prepare: (signal: AbortSignal) => Promise - /** - * Record what the sender needs before the Agent wakes. It runs - * synchronously after admission, immediately before the message enters the - * inbox. A throw keeps the message out of the inbox and reaches the sender - * unchanged; the sender reverts its own effects when `deliver` rejects. - * @param message - the admitted message. - */ - readonly beforeInsert?: (message: UserMessage) => void -} -``` - -A listener admits by returning the decision of `next()`, holds the message by awaiting before `next()`, and refuses it by returning a `reject` decision: - -```ts type-equiv -/** The outcome of the admission stage for one message. */ -type AdmissionDecision = - /** The message enters the inbox. */ - | { readonly kind: 'admit' } - /** The message stays out of the inbox. */ - | { - readonly kind: 'reject' - /** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */ - readonly reason: string - /** Explanation for a sender that shows nothing of the refusing plugin. */ - readonly message: string - /** Whether the refusing plugin explains the refusal in its own interface. */ - readonly presented: boolean - } -``` - ## Interception decisions Pre-step decisions use the same identified `UserMessage` type as durable user-role input. The entered batch is authoritative and preserves every message's `id` and `source`. Hook bridges map their native decision fields onto this typed result. @@ -497,33 +437,6 @@ interface TurnBoundaryProjection { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.agentController` — `AgentController` - -Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled. - -```ts cordis-catalog -/** - * Deliver one message from outside the Agent: prepare its content, ask the - * `agent-controller/admission` stage for a decision, then append an - * admitted message with `followup` or `steer`. - * @param agent - the receiving Agent. - * @param request - delivery mode, source, cancellation, and the sender's content preparation. - * @returns the id of the message once it is in the inbox. - * @throws {@link AgentAdmissionRejected} when a listener rejects the message - * or throws, or when the Agent left the registry first; the error of - * `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's - * cancellation reason as soon as it cancels, also while the message waits - * behind another one; the stop reason once the controller stops. - */ -async deliver(agent: Agent, request: DeliveryRequest): Promise -``` - -Types: [MessageId](llm-streaming.md) - -Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts) - ### `ctx.agentDefaultModel` — `AgentDefaultModelConfig` @@ -1186,43 +1099,6 @@ Types: [Scoped](scope.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -### `agent-controller/*` events - - - -#### `agent-controller/admission` — waterfall - -Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook. - -```ts cordis-catalog -/** - * Decide whether one message from outside an Agent enters its inbox. - * `ctx.agentController.deliver` runs this stage and appends the message - * only after the whole chain admitted it; messages the Agent's own - * machinery produces enter through `followup`, `steer`, or `inject` - * without it. Listeners run in registration order, and messages to one - * Agent are decided one at a time in arrival order. A listener admits by - * returning the decision of `next()`, whose last step admits; awaiting - * before `next()` holds the message, and returning a `reject` decision - * refuses it without asking later listeners. A thrown error means the - * listener's check itself failed. A listener must not wait for a person - * when the message source is unattended, such as a schedule or a webhook. - * @param payload.agent - the receiving Agent. - * @param payload.message - the message as it will enter the inbox. - * @param payload.signal - aborted when the sender cancels or the controller stops; a holding - * listener must settle once it aborts, because controller disposal waits for it. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent. - * @mode waterfall - */ -'agent-controller/admission'(this: Scoped, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [Scoped](scope.md) · [UserMessage](session.md) - -Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts) - ### `agent-loop/*` events diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 1ad18a947d..fcd3d50f36 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -15,10 +15,9 @@ | `tools/` | 带作用域的工具注册表与受保护的执行流水线(`ctx.tools`) | [tools.md](tools.zh.md) | | `agent/` | `Agent` 接口、实时注册表、发起者作用域与 `agent/*` 事件词汇(`ctx.agents`) | 本页 | | `agent-loop/` | 实现公开 `Agent` 约定的具体 driver(`ctx.agentLoop`) | 本页 | -| `agent-controller/` | 外部消息经 `agent-controller/admission` 阶段投递进 Agent(`ctx.agentController`) | 本页 | | `scope/` | 注册表与循环用于构建按 agent 作用域的注册原语 | [scope.md](scope.zh.md) | -`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。`agent-controller` 位于循环之前:代表他人送来消息的发送方经 `ctx.agentController` 投递,而不是自己写入收件箱。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。 +`scope/` 是这里唯一的非服务包:一个零依赖库(`createScope`/`scopeOf`/`scopeTarget`),在模块图中位于 `session/` 与 `system-prompt/` 之下,正是为了让它们消费它而不形成环。`agent-loop` 是公开 `Agent` 约定的唯一具体实现,放在这里因为它是 harness 的默认产品循环;它在 `ctx.agents.withInitiator()` 内运行每个 driver。扩展插件依赖 `agent`——包括需要发起 Agent 时——而绝不直接依赖 `agent-loop`,因此循环保持可替换。[`dsh-base`](../../packages/bundle/base/README.zh.md) 是默认产品组合,[`dsh-sdk-minimal`](../../packages/bundle/sdk-minimal/README.zh.md) 则声明一棵更小的独立配置树。 @@ -312,67 +311,6 @@ cause 是由 TypeScript 强制约束的同进程输入。活跃的取消持有 `ctx.agents` 携带的进程本地 initiator 就是上面的确切 `Agent`,不是单独的 frame 或复制的标识。环境中存在该值既不能证明存活,也不代表授权;[initiator 作用域决策](../../.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md)定义其生命周期和作用域规则。 - - -## 来自 Agent 外部的消息 - -代表他人送来消息的发送方(例如 Web Session Remote)调用 [`agent-controller`](../../packages/core/agent-controller/README.zh.md) 的 `ctx.agentController.deliver`,而不是 `followup()` 或 `steer()`。控制器按该 Agent 的投递顺序准备内容,向 `agent-controller/admission` waterfall 请求决定,再放入被接纳的消息;Agent 自身机制产生的消息不经过这个阶段。[投递决策](../../.agents/notes/implemented/architecture/2026-10-17-agent-controller-message-delivery.zh.md)说明了这个阶段为什么不放在 `agent` 里。 - -源码:[`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts) - -```ts type-equiv -/** How a delivered message enters the inbox: `followup` for its own turn, `steer` for the nearest step. */ -type DeliveryMode = 'followup' | 'steer' -``` - -```ts type-equiv -/** One message a sender delivers on someone's behalf. */ -interface DeliveryRequest { - /** Where the message enters once it is admitted. */ - readonly mode: DeliveryMode - /** Durable attribution recorded on the message. */ - readonly source: MessageSource - /** The sender's cancellation; it ends the sender's wait at once and stops a message that has not entered the inbox. */ - readonly signal: AbortSignal - /** - * Turn the sender's content into durable message content, such as by - * storing attachments. It runs in the Agent's delivery order, after the - * earlier messages to the same Agent settled; its failure reaches the - * sender unchanged. - * @param signal - aborted when the sender cancels or the controller stops; controller disposal waits for the preparation to settle. - * @returns the content of the message. - */ - readonly prepare: (signal: AbortSignal) => Promise - /** - * Record what the sender needs before the Agent wakes. It runs - * synchronously after admission, immediately before the message enters the - * inbox. A throw keeps the message out of the inbox and reaches the sender - * unchanged; the sender reverts its own effects when `deliver` rejects. - * @param message - the admitted message. - */ - readonly beforeInsert?: (message: UserMessage) => void -} -``` - -监听器返回 `next()` 的决定即放行,在 `next()` 之前 await 即暂留消息,返回 `reject` 决定即拒绝: - -```ts type-equiv -/** The outcome of the admission stage for one message. */ -type AdmissionDecision = - /** The message enters the inbox. */ - | { readonly kind: 'admit' } - /** The message stays out of the inbox. */ - | { - readonly kind: 'reject' - /** Code of the refusal, owned by its source, such as `forge/worktree-missing`. */ - readonly reason: string - /** Explanation for a sender that shows nothing of the refusing plugin. */ - readonly message: string - /** Whether the refusing plugin explains the refusal in its own interface. */ - readonly presented: boolean - } -``` - ## 拦截决策 @@ -511,33 +449,6 @@ interface TurnBoundaryProjection { Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md). - - -### `ctx.agentController` — `AgentController` - -Delivers the messages that senders, such as the Web Session Remote, bring on someone's behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled. - -```ts cordis-catalog -/** - * Deliver one message from outside the Agent: prepare its content, ask the - * `agent-controller/admission` stage for a decision, then append an - * admitted message with `followup` or `steer`. - * @param agent - the receiving Agent. - * @param request - delivery mode, source, cancellation, and the sender's content preparation. - * @returns the id of the message once it is in the inbox. - * @throws {@link AgentAdmissionRejected} when a listener rejects the message - * or throws, or when the Agent left the registry first; the error of - * `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender's - * cancellation reason as soon as it cancels, also while the message waits - * behind another one; the stop reason once the controller stops. - */ -async deliver(agent: Agent, request: DeliveryRequest): Promise -``` - -Types: [MessageId](llm-streaming.zh.md) - -Source: [`packages/core/agent-controller/src/index.ts`](../../packages/core/agent-controller/src/index.ts) - ### `ctx.agentDefaultModel` — `AgentDefaultModelConfig` @@ -1200,43 +1111,6 @@ Types: [Scoped](scope.zh.md) Source: [`packages/core/agent/src/runtime-types.ts`](../../packages/core/agent/src/runtime-types.ts) - - -### `agent-controller/*` events - - - -#### `agent-controller/admission` — waterfall - -Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent's own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener's check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook. - -```ts cordis-catalog -/** - * Decide whether one message from outside an Agent enters its inbox. - * `ctx.agentController.deliver` runs this stage and appends the message - * only after the whole chain admitted it; messages the Agent's own - * machinery produces enter through `followup`, `steer`, or `inject` - * without it. Listeners run in registration order, and messages to one - * Agent are decided one at a time in arrival order. A listener admits by - * returning the decision of `next()`, whose last step admits; awaiting - * before `next()` holds the message, and returning a `reject` decision - * refuses it without asking later listeners. A thrown error means the - * listener's check itself failed. A listener must not wait for a person - * when the message source is unattended, such as a schedule or a webhook. - * @param payload.agent - the receiving Agent. - * @param payload.message - the message as it will enter the inbox. - * @param payload.signal - aborted when the sender cancels or the controller stops; a holding - * listener must settle once it aborts, because controller disposal waits for it. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent. - * @mode waterfall - */ -'agent-controller/admission'(this: Scoped, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise): Promise -``` - -Types: [Scoped](scope.zh.md) · [UserMessage](session.zh.md) - -Source: [`packages/core/agent-controller/src/types.ts`](../../packages/core/agent-controller/src/types.ts) - ### `agent-loop/*` events diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 08b9af2cde..4933734d57 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -930,14 +930,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('fork') async fork(request: SessionForkRequest): Promise /** - * Admit one prompt after explicitly resuming its Session. The prompt is - * delivered through `ctx.agentController`, whose `agent-controller/admission` - * stage runs before it enters the inbox, so the call can wait for a listener - * that holds it. + * Admit one prompt after explicitly resuming its Session. * @param request - Session identity, prompt content, source metadata, and delivery mode. - * @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage. + * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. - * @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails. */ @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index 924d68c93e..a3e5446b61 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -934,14 +934,10 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' | @Remote('fork') async fork(request: SessionForkRequest): Promise /** - * Admit one prompt after explicitly resuming its Session. The prompt is - * delivered through `ctx.agentController`, whose `agent-controller/admission` - * stage runs before it enters the inbox, so the call can wait for a listener - * that holds it. + * Admit one prompt after explicitly resuming its Session. * @param request - Session identity, prompt content, source metadata, and delivery mode. - * @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage. + * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. - * @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails. */ @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise diff --git a/docs/web-styling.i18n.yaml b/docs/web-styling.i18n.yaml index 7ae57054d0..28ee616353 100644 --- a/docs/web-styling.i18n.yaml +++ b/docs/web-styling.i18n.yaml @@ -9,8 +9,11 @@ en: dc8c41fbf9869179 zh: e45976a585cb9c86 /web-ui-style-reference/component-rules: - en: 6715806a6f84d141 - zh: 94b7a6ede4919512 + en: 4ee4a6fd2917306a + zh: d7d68c140a698ab0 +/web-ui-style-reference/input-capabilities: + en: bf2e98bce68743e2 + zh: fc58dcbb60bb944a /web-ui-style-reference/corner-radii-and-settings-cards: en: 118972f6c2bd6ce4 zh: 85f8416b03e4dd8b diff --git a/docs/web-styling.md b/docs/web-styling.md index 5efc816e28..f2ae2a6ffd 100644 --- a/docs/web-styling.md +++ b/docs/web-styling.md @@ -21,7 +21,7 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Keep source text, terminal output, and diff lines unwrapped when their component contract requires column preservation; use the shared scrollbar styles rather than component-specific scrollbar selectors. - Put presentation in CSS. Inline React styles may pass component-local custom-property values but must not encode theme branches. - A relative `url()` in a plugin stylesheet points to a file in the package's `src/client/assets/`, and the build rewrites it to the [package asset](../packages/client/AGENTS.md#dependency-declaration) URL. `data:`, `#fragment`, `/`-rooted, scheme, and empty `url('')` references stay unchanged, a relative file reference with a fragment or query fails the build, and stylesheets from dependencies are not rewritten. -- Preserve keyboard focus visibility and reduced-motion behavior when adding transitions or hover-only controls. +- Put `:hover` selectors under `@media (hover: hover)`, independently of viewport width. Keep selected, expanded, pressed, and keyboard-focus styles outside that condition. Without hover, actions remain visible or accessible through an explicit control; pointer handlers ignore touch when opening hover-only previews. Preserve keyboard focus visibility and reduced-motion behavior. - Rounded corners inherit the global superellipse smoothing from ui-theme's `corner-shape.css` on supporting engines. Pair `corner-shape: round` with every full-round `border-radius` (`50%`, `100%`, or a pill radius) so circles and capsules keep circular arcs; the ui-theme corner-shape spec enforces the pairing. - Elevated surfaces (menus, popovers, modals, panels, floating buttons, the composer) set `border: 0` and take `box-shadow: var(--dsw-elevation-panel)`, `var(--dsw-elevation-prominent)`, or the composer's `var(--dsw-elevation-soft)` (larger blur at lower alpha): the 0.5px hairline stroke is the first shadow layer, and `--dsw-elevation-stroke-color` rebinds or suppresses it per surface or state. Never pair a `--dsw-alias-border-*` border with an lv/elevation shadow — the ui-theme elevation spec rejects the pairing; state-colored borders (warn panels) stay real borders. - Existing and new dropdown, context, submenu, and selection menus use `Menu` or wrap custom content in `MenuSurface`. The material is the theme-owned `--dsw-menu-surface-fill` plus `--dsw-menu-backdrop-filter`; feature and platform CSS must not override either. The ui-theme menu spec rejects unwrapped menu/listbox renderers, material overrides, and token redefinitions. The schedule-owned `TaskMenu` and `ClockPicker` retain their existing containers and material as explicit exceptions ([owner](../packages/client/ui-schedule/README.md)). @@ -30,6 +30,17 @@ Global style sheets belong in `ui-theme/src/styles/`. Component styles live besi - Flat borders and separators that use a neutral `--dsw-alias-border-*` token draw at `0.5px` — buttons, inputs, cards, row dividers, and separators drawn as filled boxes (menu separators, the conversation header seam, markdown `hr`, vertical rails) share the hairline weight, which Chromium paints as one device pixel. Dashed affordances and state-colored borders keep 1px; spinner ring tracks keep their width through the spec's explicit allowlist. The ui-theme elevation spec rejects wider neutral solid borders. - Clickable artifact links (markdown anchors, prose file mentions, web source and fetch links, produced-file chips, workflow member links) color through `--dsw-alias-link` at `font-weight: 500`, with no underline at rest and a dotted 3px-offset underline on hover/focus. Compact Thinking Markdown keeps tertiary text color and a resting dotted underline ([compact presentation](../packages/client/ui-primitives/README.md#rendering-agent-output)). Text-leading anchors also lead with the ui-primitives `LinkIconMedium` category glyph riding `currentColor`, which for a well-known external host is that site's own mark instead of the globe; workflow member links and image-only anchors carry no glyph, and tool-row file links keep their grey dotted affordance. + +## Input capabilities + +`pointer` and `hover` describe the primary pointing device chosen by the browser; `any-hover` considers every available pointing device. None detects a physical keyboard. + +- Use `(pointer: coarse)` for presentation that depends on pointing accuracy, such as touch-sized controls and text fields. +- Keep CSS hover effects under `(hover: hover)` so a touch-first layout remains usable without hover. +- JavaScript hover previews use `(any-hover: hover)` when accepting mouse entry and ignore touch entry. A secondary mouse can therefore open a preview even when the touchscreen remains the primary pointer. Enabling all CSS hover styles through `any-hover` would also enable them for touch-generated `:hover` states. + +On a mixed-input device, a mouse preview can appear while the primary-pointer CSS hover style stays inactive. Keyboard focus and shortcut hints follow actual keyboard interaction independently of these capability queries; [Menu and input modality](../packages/client/ui-primitives/README.md#input-modality) define the shared behavior. + ## Corner radii and settings cards diff --git a/docs/web-styling.zh.md b/docs/web-styling.zh.md index 2bb8cca6ac..6ad928862e 100644 --- a/docs/web-styling.zh.md +++ b/docs/web-styling.zh.md @@ -21,7 +21,7 @@ - 当组件约定要求保留列结构时,源码文本、终端输出和 diff 行不得换行;使用共享滚动条样式,不得定义组件专用滚动条选择器。 - 呈现规则写在 CSS 中。React 内联样式可以传递组件局部自定义属性值,但不得编码主题分支。 - 插件样式表中的相对 `url()` 指向本包 `src/client/assets/` 中的文件,构建会将其改写为[包资源](../packages/client/AGENTS.md#dependency-declaration) URL。`data:`、`#fragment`、以 `/` 开头、带 scheme 的引用以及空 `url('')` 引用保持不变,带片段或查询串的相对文件引用会让构建失败,依赖包中的样式表不会被改写。 -- 添加过渡动画或仅悬停可见的控件时,保留清晰可见的键盘焦点和减少动态效果行为。 +- 将 `:hover` 选择器放在 `@media (hover: hover)` 内,不依赖视口宽度。选中、展开、按下和键盘焦点样式保留在该条件之外。无 hover 时,操作保持可见或通过明确控件访问;打开纯悬停预览的指针事件处理忽略触摸。保留清晰可见的键盘焦点和减少动态效果行为。 - 支持的引擎上,圆角继承 ui-theme `corner-shape.css` 的全局超级椭圆平滑。每个正圆 `border-radius`(`50%`、`100%` 或胶囊半径)必须配对 `corner-shape: round`,使圆形与胶囊保持圆弧;ui-theme 的 corner-shape spec 强制这一配对。 - 高层级表面(菜单、浮层、对话框、面板、悬浮按钮、输入框)设 `border: 0` 并使用 `box-shadow: var(--dsw-elevation-panel)`、`var(--dsw-elevation-prominent)` 或输入框专用的 `var(--dsw-elevation-soft)`(更大模糊、更低透明度):0.5px 发丝描边是第一层投影,`--dsw-elevation-stroke-color` 可按表面或状态重绑或抑制描边。不得将 `--dsw-alias-border-*` border 与 lv/elevation 投影配对——ui-theme 的 elevation spec 会拒绝;状态色 border(warn 面板)保持真 border。 - 存量和新增的下拉菜单、上下文菜单、子菜单及选择菜单使用 `Menu`,自定义内容用 `MenuSurface` 包裹。材质由主题的 `--dsw-menu-surface-fill` 和 `--dsw-menu-backdrop-filter` 组成;功能及平台 CSS 不得覆盖两者。ui-theme 菜单检查拒绝未使用共享容器的 menu/listbox 渲染、材质覆盖和 token 重定义。计划任务自有的 `TaskMenu` 与 `ClockPicker` 是明确例外,保留现有容器和材质([所属包](../packages/client/ui-schedule/README.zh.md))。 @@ -30,6 +30,17 @@ - 使用中性 `--dsw-alias-border-*` token 的平面边框与分割线一律 `0.5px`——按钮、输入框、卡片、行分割线,以及以填充盒绘制的分隔线(菜单分隔、对话标题栏接缝、markdown `hr`、竖向轨道线)共用发丝线粗细,Chromium 将其绘制为一个设备像素。dashed 记号与状态色 border 保持 1px;spinner 圆环经 spec 的显式豁免保留原宽度。更宽的中性 solid border 会被 ui-theme elevation spec 拒绝。 - 可点击产物链接(Markdown 锚点、正文文件引用、网页来源与抓取链接、产物 chips、workflow 成员链接)经 `--dsw-alias-link` 着色、`font-weight: 500`,默认无下划线,hover/focus 时为 3px offset 的点状下划线。紧凑 Thinking Markdown 保持 tertiary 文字色和默认点状下划线([紧凑展示](../packages/client/ui-primitives/README.zh.md#rendering-agent-output))。带文字的锚点另以 ui-primitives 的 `LinkIconMedium` 分类图形(随 `currentColor`)作前置;目的地是已知外部站点时改用该站点自己的标记而非地球;workflow 成员链接与只包图片的锚点不带图形,工具行文件链接保持其灰色点线示能。 + +## 输入能力 + +`pointer` 与 `hover` 描述浏览器选定的主指针,`any-hover` 则考虑所有可用指针。三者都不检测物理键盘。 + +- `(pointer: coarse)` 用于依赖指针精度的呈现,例如适合触摸的控件和文本框尺寸。 +- CSS 悬停效果保留在 `(hover: hover)` 下,使以触屏为主的布局无需悬停也能使用。 +- JavaScript 悬停预览在接受 mouse 进入事件时使用 `(any-hover: hover)`,并忽略 touch 进入事件。因此,即使触屏仍是主指针,外接鼠标也能打开预览。如果通过 `any-hover` 启用全部 CSS 悬停样式,触摸产生的 `:hover` 状态也会应用这些样式。 + +混合输入设备上,鼠标预览可以出现,而基于主指针的 CSS 悬停样式保持关闭。键盘焦点和快捷键提示根据实际键盘交互显示,不依赖这些能力查询;共享行为见 [Menu 与输入方式](../packages/client/ui-primitives/README.zh.md#input-modality)。 + ## 圆角与设置卡片 diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml index 6eff59fa2a..ff8ee89c2f 100644 --- a/packages/api/session-controller/README.i18n.yaml +++ b/packages/api/session-controller/README.i18n.yaml @@ -15,8 +15,8 @@ en: 1fdf707322fb6996 zh: 0204f7e9d4aa3cce /session-controller/use-this-package: - en: fe242dda6579245e - zh: cbd8c012135623de + en: 8fcfa14545bd9d1c + zh: c10c2bba792699ef /session-controller/client-references: en: 41bdda1ac32df558 zh: aa5d0e325924066b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md index 7da4c66b5a..fbc0aeaa3b 100644 --- a/packages/api/session-controller/README.md +++ b/packages/api/session-controller/README.md @@ -33,7 +33,7 @@ History pages and follow opening snapshots carry one `{ type: 'event', event: Se The Client journal validates current Session event envelopes before publishing follow snapshots, live entries, or history pages. It reuses the browser-safe Session validators for required surface markers, exact replacement endpoints, earlier unique source seqs, embedded Assistant provider metadata, request-header omissions, and tool-error consistency. Invalid records fail without field stripping or normalization; range membership and source existence remain durable-log checks on the Host. -Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. A prompt enters the Agent through [`ctx.agentController.deliver`](../../core/agent-controller/README.md#use-this-package), which runs the receipt resolution and attachment storage in the Agent's delivery order and binds the receipts immediately before the message enters the inbox, so the call stays open while an `agent-controller/admission` listener holds it. An image prompt takes the Session's image admission turn when its preparation starts and keeps it until the prompt settles, so a model selection lands either before its image check or after it entered the inbox, and waits while a listener holds that image prompt. A refusal returns `session/prompt-rejected` with the listener's `reason` and `presented`, a listener failure returns the same code with reason `agent-controller/admission-failed`, and an Agent disposed meanwhile returns `session/not-found`. A client that predates `session/prompt-rejected` shows it as an ordinary failure with its message and code. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. +Each endpoint states its activation policy. List reads only stored headers and projection-cache rows: it never calls per-session stat or opens a cold Session body. A current-format cache identity may supply every list hint; a lifecycle-matching predecessor cache may supply only its version-compatible title as a stale display fact, never as an authoritative fold seed. Search, attachment, history pages, log following, skill discovery, and workspace-path opening can inspect persistence without activating an Agent; `canOpenWorkspacePath()` reports native-opening availability without addressing a Session. Cancellation requires live state; queue mutation, model, rename, prompt, and file-reference operations may resolve or resume an ordinary Session. Prompt rejects content with neither non-whitespace text nor an attachment before resolving the Agent or appending Session events; queue edits accept only non-empty text content. Prompt admission consumes opaque receipts from the injected [`fileUploads`](../../client/file-upload/README.md) Host service and resolves every same-Agent receipt before sending the complete ordered content list through `ctx.attachments`. Prompt retries whose `requestId` is already queued or logged return the original acceptance without inserting another message. Create and fork are the only operations that create a new Agent directly. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. Queue mutation has one narrow exception: a live child whose current projected identity is continuable and comes from its own non-seed suffix accepts the ordinary Edit, Remove, and QueueDock Steer actions across both inbox destinations. One-shot, missing, unknown, corrupt, seed-only, or cold children remain rejected without resume. The skill catalog uses a live Agent's effective working directory and scoped registry when present. Cold listing uses the projected current directory, falling back to the recorded origin and then the configured directory default. It retains the preset standing scope without activating an Agent. The authenticated delivery routes use `workspaceDesktop()` for the serving Host name and file-manager behavior. `openWorkspacePath({ path, action: "reveal" })` delegates file-manager navigation to the native adapter; omitting `action` opens the file-type association, including HTML and SVG. Both operations require the composed filesystem to map the requested Host path to the same canonical process path; unmapped remote paths are refused before a native command runs. `session.projections` normally reads an exact baseline, including current-format cold logs, and returns `kind: sequenced`. Only a read requiring migration returns `kind: migration-required` hints without `asOfSeq`; missing keys remain absent. It returns null for a missing Session. Fork callers may set `allowMigration: false` to receive `session/migration-required` instead of starting source migration; omission retains automatic migration. The Client exposes shared values and explicit-read state through `projectionsBySession`; domains select their own keys. Session-list summaries carry `agentAvailable`, updated through existing summary and status events independently of durable projections. Sequenced reads and live projection frames use the same sequence ordering; cached reads fill hints without replacing sequenced values. Every Remote endpoint is access-controlled: a Peer whose principal is not the owner reaches an endpoint only when, on every resource the call touches, a binding of that principal kind's policy in [`dsh-api-remote-access`](../remote-access/README.md) grants a role listing the action ([Peer principals Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.md)). The controller injects `remoteAccess` and registers the `session` resource kind with the default field `sessionId` through `typert.resources`. `session/page` and `session/follow` declare a `session` reader in `@Remote({ resources })` that reads `request.address.sessionId` for an ordinary Session address and `request.address.childSessionId` for a subagent address; whether access to the parent Session covers its children is the policy's rule. `session/projections` reads the Session resource from `request.sessionId`, so a reconnecting reader reloads only the projections of Sessions it may read. `session/list`, `session/search`, and `session/control` read no resource, so their calls touch the `global` resource and need a `global` role; for a non-owner caller, `session/list` returns only the rows, `session/search` only the hits, and `session/control` only the baseline projections and projection frames, whose Session `ctx.remoteAccess.allows()` admits for the same action, so that action also belongs to a `session` role; owner results are unfiltered. `session/prompt` builds the durable `user-rpc` message source in its Remote method and records `principal` only for a non-owner caller, read through `principalOf(this.ctx.invocation)`; a source without `principal` belongs to the owner. diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md index 2174adec49..d63735689f 100644 --- a/packages/api/session-controller/README.zh.md +++ b/packages/api/session-controller/README.zh.md @@ -33,7 +33,7 @@ kind: "package-reference" Client journal 在发布 follow 快照、live entry 或历史页之前验证当前 Session 事件 envelope。它复用浏览器安全的 Session validator,检查必需的 surface marker、精确的 replacement endpoint、更早且唯一的 source seq、内嵌 Assistant 提供方元数据、request header 可选字段的省略规则以及工具错误一致性。无效 record 直接失败,不删除字段或归一化;范围成员与来源存在性仍由 Host 的持久日志检查。 -每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。提示词经 [`ctx.agentController.deliver`](../../core/agent-controller/README.zh.md#use-this-package) 进入 Agent:控制器按该 Agent 的投递顺序完成凭证解析与附件存储,并在消息进入收件箱前一刻绑定凭证,因此 `agent-controller/admission` 监听器暂留它期间调用保持未完成。图片提示词在开始准备时取得该 Session 的图片准入顺位,并持有到提示词结束,因此模型切换要么发生在它的图片检查之前,要么发生在它进入收件箱之后;监听器暂留这条图片提示词期间,模型切换会等待。被拒绝时返回 `session/prompt-rejected`,并带上监听器给出的 `reason` 与 `presented`;监听器失败时返回同一错误码,reason 为 `agent-controller/admission-failed`;期间 Agent 被销毁则返回 `session/not-found`。早于 `session/prompt-rejected` 的旧客户端把它显示为普通失败,带消息与错误码。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 +每个 endpoint 都声明自己的激活策略。列表只读取持久化 header 与 projection cache row,绝不调用逐 Session stat 或打开冷 Session body。当前格式 cache identity 可以提供全部列表 hint;生命周期匹配的 predecessor cache 只能提供版本兼容的 title,作为可能过时的展示事实,绝不能作为权威 fold seed。搜索、附件、历史页、日志跟随、skill 发现和工作区路径打开可以在不激活 Agent 的情况下检查 persistence;`canOpenWorkspacePath()` 无需指定 Session 即可报告原生打开能力。取消要求 live 状态;queue 变更、模型、重命名、prompt 和文件引用操作可以解析或恢复普通 Session。提示词会在解析 Agent 或追加 Session 事件前,拒绝既没有非空白文本也没有附件的 content;queue edit 只接受非空文本 content。prompt 准入从注入的 [`fileUploads`](../../client/file-upload/README.zh.md) Host 服务取得不透明凭证,在把完整有序内容列表交给 `ctx.attachments` 前解析每个属于同一 Agent 的凭证。`requestId` 已进入 queue 或日志时,prompt 重试直接返回原来的接受结果,不会重复插入消息。只有 create 与 fork 会直接创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。Queue 变更只有一个狭窄例外:当前 projection identity 为 continuable 且来自自身非 seed suffix 的在线 child,可以在两个 inbox 目标上使用普通 Edit、Remove 与 QueueDock Steer action。One-shot、缺失、未知、损坏、仅含 seed identity 或冷 child 继续被拒绝,且不会恢复。skill 目录优先使用已有 live Agent 的有效工作目录与限定范围内的注册表。冷态列表使用投影中的当前目录,依次回退到记录的起始目录与配置的默认目录。它保留 preset 常驻 scope,不会激活 Agent。经过鉴权的文件交付路由通过 `workspaceDesktop()` 获取提供服务的 Host 名称和文件管理器行为。`openWorkspacePath({ path, action: "reveal" })` 将文件管理器导航委托给原生适配器;省略 `action` 时按文件类型关联打开,包括 HTML 和 SVG。两种操作都要求当前文件系统将请求的 Host 路径映射到同一个规范进程路径;无法映射的远端路径会在执行原生命令之前被拒绝。 `session.projections` 正常读取准确基线,包括当前格式的冷日志,返回 `kind: sequenced`。只有需要迁移时才返回不带 `asOfSeq` 的 `kind: migration-required` 提示,缺少的键保持缺席。Session 不存在时返回 null。Fork 调用者可设置 `allowMigration: false`,在源历史需要迁移时收到 `session/migration-required`;省略则保持自动迁移。Client 通过 `projectionsBySession` 暴露共享值和显式读取状态,由领域选择自身的 key。Session 列表摘要携带 `agentAvailable`,通过已有摘要与状态事件更新,与持久化 projection 相互独立。有序读取与实时 projection 帧使用相同的序号排序规则;缓存读取只补充提示,不替换有序值。 每个 Remote 端点都受访问控制:principal 不是 owner 的 Peer 只有在本次调用访问的每个资源上,该 principal kind 在 [`dsh-api-remote-access`](../remote-access/README.zh.md) 中的 policy 都有某个 binding 授予列出该 action 的 role 时,才能调用该端点([Peer 身份属性 Agent Note](../../../.agents/notes/implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md))。控制器注入 `remoteAccess`,并经 `typert.resources` 以默认字段 `sessionId` 登记 `session` 资源种类。`session/page` 与 `session/follow` 在 `@Remote({ resources })` 中声明 `session` 读取函数:对普通 Session 地址读 `request.address.sessionId`,对 subagent 地址读 `request.address.childSessionId`;父会话的访问权是否覆盖其子会话由 policy 决定。`session/projections` 从 `request.sessionId` 读取会话资源,因此重连的读者只会重新读取它可读的会话的 projections。`session/list`、`session/search` 与 `session/control` 不读取资源,其调用访问 `global` 资源,需要 `global` role;对非 owner 调用方,`session/list` 只返回其 Session 被 `ctx.remoteAccess.allows()` 按同一 action 允许的行,`session/search` 只返回这类 Session 的命中,`session/control` 也只返回这类 Session 的 baseline projection 与 projection 帧,因此该 action 也要列在 `session` role 中;owner 的结果不过滤。`session/prompt` 在其 Remote 方法内构造持久的 `user-rpc` 消息 source,且只为非 owner 调用方记录经 `principalOf(this.ctx.invocation)` 读取的 `principal`;不带 `principal` 的 source 属于 owner。 diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json index 07b1583b66..faf27a4fa3 100644 --- a/packages/api/session-controller/package.json +++ b/packages/api/session-controller/package.json @@ -80,7 +80,6 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-agent-controller": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", "@deepseek-ai/dsh-agent-preset-registry": "workspace:*", "@deepseek-ai/dsh-api-gateway": "workspace:*", @@ -126,7 +125,6 @@ "@deepseek-ai/dsh-api-remote-access": "workspace:*", "@deepseek-ai/cordis": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-agent-controller": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:*", diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts index 0900765d7d..4dee6de84a 100644 --- a/packages/api/session-controller/src/agent.ts +++ b/packages/api/session-controller/src/agent.ts @@ -387,27 +387,6 @@ export class ApiSessionAgentController { return result } - /** - * Take the image admission turn for one Agent: wait until earlier image - * admissions and model selections settled, then hold the turn until the - * returned release runs. - * @param agent - live Agent that owns the serialization chain. - * @param signal - the waiting caller's cancellation; a caller cancelled by its turn passes the turn on. - * @returns the release, which the caller runs once its image prompt settled. - * @throws the cancellation reason when the caller was cancelled by its turn. - */ - acquireImageAdmission(agent: Agent, signal: AbortSignal): Promise<() => void> { - const previous = this.imageAdmissionChains.get(agent) ?? Promise.resolve() - const held = Promise.withResolvers() - this.imageAdmissionChains.set(agent, previous.then(() => held.promise)) - const release = (): void => { held.resolve(undefined) } - return previous.then(() => { - if (signal.aborted) release() - signal.throwIfAborted() - return release - }) - } - /** * Resolve the preset id and pre-publication Agent setup for a create or resume. * @param presetId - requested preset or the configured default when omitted. diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts index 24e6a3e2d2..8dd38573c8 100644 --- a/packages/api/session-controller/src/commands.ts +++ b/packages/api/session-controller/src/commands.ts @@ -5,15 +5,14 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import { brandString } from '@deepseek-ai/dsh-brand' import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' -import type { ADMISSION_AGENT_GONE, ADMISSION_REJECTED_CODE, AgentAdmissionRejected } from '@deepseek-ai/dsh-agent-controller' import { AttachmentError } from '@deepseek-ai/dsh-attachment' import type { AttachmentAdmissionPart, FileAttachmentRef, ImageAttachmentRef, } from '@deepseek-ai/dsh-attachment' import type { FileUploadReceiptId } from '@deepseek-ai/dsh-client-file-upload/types' -import type { PromptFileBinding } from '@deepseek-ai/dsh-client-file-upload' +import type {} from '@deepseek-ai/dsh-client-file-upload' import { - ReasoningEffortId, assistantStreamChunks, freezeMessage, + ReasoningEffortId, assistantStreamChunks, createUserMessage, freezeMessage, } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' import { buildForkSeed } from '@deepseek-ai/dsh-session/fork' @@ -311,72 +310,56 @@ export class SessionCommandController { } /** - * Deliver one prompt through the Agent controller, whose admission stage - * may hold or refuse it; the Remote method has already validated the - * content and built the durable message source. The controller runs this - * Session's content preparation (image model check, upload receipts, - * attachment storage) in the Agent's delivery order and binds the upload - * receipts immediately before the message enters the inbox. A repeated - * request identity is acknowledged without delivering it again. An image - * prompt takes the Session's image admission turn when its preparation - * starts and keeps it until the prompt settles, so a model selection lands - * either before its image check or after it entered the inbox; a selection - * waits while a listener holds that image prompt. + * Admit one prompt after Agent and attachment validation; the Remote method + * has already validated the content and built the durable message source. * @param request - Session identity, prompt content, and delivery mode. * @param source - the `user-rpc` source recorded on the accepted message. - * @param signal - the requesting client's cancellation; it stops a prompt that waits for its turn or is held at the admission stage. * @returns acknowledgement that the Agent accepted the prompt. - * @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails. */ - async prompt(request: SessionPromptRequest, source: MessageSource, signal: AbortSignal): Promise { + async prompt(request: SessionPromptRequest, source: MessageSource): Promise { const agent = await this.resolveAgent(request.sessionId) if (hasPromptRequest(agent, request.requestId)) return { accepted: true } const hasImage = request.content.some(part => part.type === 'image') - const uploads: { receiptIds: readonly FileUploadReceiptId[]; binding?: PromptFileBinding } = { receiptIds: [] } - const imageTurn: { release?: () => void } = {} - try { - await this.ctx.agentController.deliver(agent, { - mode: request.mode === 'steer' ? 'steer' : 'followup', - source, - signal, - prepare: async (preparing) => { - if (hasImage) { - // Taken in the Agent's delivery order, so a waiting image prompt keeps its place among the others. - imageTurn.release = await this.agents.acquireImageAdmission(agent, preparing) - await this.requireImageInput(agent) + const admit = async (): Promise => { + try { + if (hasImage) { + const current = this.agents.selectionFor(agent).current + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + throw new RemoteError( + 'session/attachment-invalid', + `Model "${current.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) } - const admission = resolvePromptFileReceipts( - request.content, - receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + } + const admission = resolvePromptFileReceipts( + request.content, + receiptId => this.ctx.fileUploads.resolve(agent, receiptId), + ) + const content = await this.ctx.attachments.admitPromptContent(admission.content) + const message: UserMessage = createUserMessage({ content, source }) + if (this.ctx.agents.get(agent.id) !== agent) { + throw new RemoteError( + 'session/not-found', + `session "${agent.id}" was disposed during prompt admission`, + { sessionId: agent.id }, ) - uploads.receiptIds = admission.receiptIds - return await this.ctx.attachments.admitPromptContent(admission.content) - }, - beforeInsert: () => { - uploads.binding = this.ctx.fileUploads.bindPrompt(agent, uploads.receiptIds, request.requestId) - }, - }) - uploads.binding?.commit() - } catch (error) { - throw promptFailure(agent, error, signal) - } finally { - uploads.binding?.[Symbol.dispose]() - imageTurn.release?.() - } - return { accepted: true } - } - - /** Refuse an image prompt when the Session's current model does not declare image input. */ - private async requireImageInput(agent: Agent): Promise { - const current = this.agents.selectionFor(agent).current - const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) - if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { - throw new RemoteError( - 'session/attachment-invalid', - `Model "${current.model}" does not support image input.`, - { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, - ) + } + using binding = this.ctx.fileUploads.bindPrompt(agent, admission.receiptIds, request.requestId) + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + binding.commit() + } catch (error) { + if (remoteErrorOf(error) !== undefined) throw error + if (error instanceof AttachmentError) { + throw new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) + } + throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) }) + } + return { accepted: true } } + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() } private async requireModel(selection: Pick): Promise { @@ -577,36 +560,6 @@ export class SessionCommandController { } } -// Typed by the agent-controller constants, so a changed value fails this module's build while it imports no agent-controller value. -const ADMISSION_REJECTED: typeof ADMISSION_REJECTED_CODE = 'AGENT_ADMISSION_REJECTED' -const AGENT_GONE: typeof ADMISSION_AGENT_GONE = 'agent-controller/agent-gone' - -/** Recognize an admission refusal by the code that the error of every copy of the agent-controller module carries. */ -function isAdmissionRejected(error: unknown): error is AgentAdmissionRejected { - return error instanceof Error && 'code' in error && error.code === ADMISSION_REJECTED -} - -/** - * Map a failed prompt admission onto the Session Remote's failures: Remote - * failures and the client's own cancellation pass unchanged, and an admission - * refusal keeps its reason and whether its plugin presents it. - */ -function promptFailure(agent: Agent, error: unknown, signal: AbortSignal): unknown { - if (remoteErrorOf(error) !== undefined || signal.aborted) return error - if (isAdmissionRejected(error)) { - if (error.reason === AGENT_GONE) { - return new RemoteError('session/not-found', `session "${agent.id}" was disposed during prompt admission`, { - sessionId: agent.id, - }) - } - return new RemoteError('session/prompt-rejected', error.message, { reason: error.reason, presented: error.presented }) - } - if (error instanceof AttachmentError) { - return new RemoteError('session/attachment-invalid', error.message, { reason: error.code }) - } - return new RemoteError('session/agent-busy', 'prompt rejected', { reason: String(error) }) -} - function resolvePromptFileReceipts( content: SessionPromptRequest['content'], stagedFile: (receiptId: FileUploadReceiptId) => FileAttachmentRef | undefined, diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index d060eab5fb..5666d8dc08 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -117,7 +117,6 @@ export interface SessionControllerInternals { /** Host service backing the generated `ctx.remote.session` namespace. */ export class SessionController extends TypertRemoteService { static inject = [ - 'agentController', 'agentDefaultModel', 'agents', 'attachments', @@ -459,14 +458,10 @@ export class SessionController extends TypertRemoteService { } /** - * Admit one prompt after explicitly resuming its Session. The prompt is - * delivered through `ctx.agentController`, whose `agent-controller/admission` - * stage runs before it enters the inbox, so the call can wait for a listener - * that holds it. + * Admit one prompt after explicitly resuming its Session. * @param request - Session identity, prompt content, source metadata, and delivery mode. - * @param signal - caller cancellation; it also stops a prompt waiting for or held at the admission stage. + * @param signal - caller cancellation before prompt admission begins. * @returns acknowledgement that the Agent accepted the prompt. - * @throws `session/prompt-rejected` when an admission listener refuses the prompt or fails. */ @Remote('prompt') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { @@ -492,7 +487,7 @@ export class SessionController extends TypertRemoteService { ...(clientTimeZone === undefined ? {} : { clientTimeZone }), ...(principal === undefined ? {} : { principal }), } - return this.commands.prompt(request, source, signal) + return this.commands.prompt(request, source) } /** diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts index 661054dbae..f4d2daa637 100644 --- a/packages/api/session-controller/src/types.ts +++ b/packages/api/session-controller/src/types.ts @@ -212,12 +212,6 @@ declare module '@deepseek-ai/dsh-typert-protocol' { 'session/projections-unavailable': Record 'session/writer-held': { readonly sessionId: SessionId } 'session/agent-busy': { readonly reason: string } - 'session/prompt-rejected': { - /** Code of the refusal, owned by the plugin that refused, such as `forgePanel/worktree-missing`. */ - readonly reason: string - /** Whether the refusing plugin shows its own explanation, so the client adds no notice. */ - readonly presented: boolean - } 'session/invalid-time-zone': { readonly value: string } 'session/workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } 'agent-preset/conflict': { diff --git a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts index 51d1ade93d..48ba896f01 100644 --- a/packages/api/session-controller/tests/commands-upload-file.host.spec.ts +++ b/packages/api/session-controller/tests/commands-upload-file.host.spec.ts @@ -1,6 +1,5 @@ import { Context, FiberState } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' -import AgentController from '@deepseek-ai/dsh-agent-controller' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' @@ -39,7 +38,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ await ctx.plugin(SessionStore) await ctx.plugin(SessionProjectionRegistry) await ctx.plugin(AgentRegistry) - await ctx.plugin(AgentController) await ctx.plugin(CommandRuntime) const session = ctx.sessions.create(SESSION, { meta: { cwd: '/workspace', ...(origin === undefined ? {} : { origin }) }, @@ -101,8 +99,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ selectionFor: () => selection, serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), } as unknown as ApiSessionAgentController - // Image prompts take their admission turn at once; no model selection competes in these tests. - Object.assign(agents, { acquireImageAdmission: () => Promise.resolve(() => {}) }) const uploads = new FileUploads(ctx) if (uploadRoute === undefined) throw new Error('file upload route was not registered') return { @@ -121,8 +117,6 @@ async function uploadHarness(origin?: 'subagent'): Promise<{ /** The owner source `SessionController.prompt()` builds for {@link promptRequest}. */ const USER_SOURCE: MessageSource = { kind: 'user', rpcId: 'req-1' as SessionRequestId } -/** A client that stays connected for the whole prompt. */ -const SIGNAL = new AbortController().signal function promptRequest(content: Parameters[0]['content']) { return { @@ -174,7 +168,7 @@ describe('Session file uploads', () => { { type: 'file', receiptId: receipt.receiptId }, { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'text', text: 'read it' }, - ]), USER_SOURCE, SIGNAL) + ]), USER_SOURCE) expect(followup).toHaveBeenCalledTimes(1) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ @@ -201,7 +195,7 @@ describe('Session file uploads', () => { name: 'huge.bin', })) expect(receipt.file).toMatchObject({ name: 'huge.bin', bytes: 5 }) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ { type: 'file', attachment: receipt.file }, ]) @@ -228,7 +222,7 @@ describe('Session file uploads', () => { await expect(controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, { type: 'file', receiptId: 'missing-receipt' as FileUploadReceiptId }, - ]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/attachment-invalid', details: { reason: 'FILE_NOT_STAGED' } }) expect(followup).not.toHaveBeenCalled() expect(saveImages).not.toHaveBeenCalled() }) @@ -326,7 +320,7 @@ describe('Session file uploads', () => { const { controller, uploads, agent } = await uploadHarness() uploads.retirePrompt(agent, 'not-staged') const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) uploads.retirePrompt(agent, 'other-request') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) @@ -341,7 +335,7 @@ describe('Session file uploads', () => { { data: 'AAAA' }, new AbortController().signal, ) - await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE, SIGNAL) + await first.controller.prompt(promptRequest([{ type: 'file', receiptId: observed.receiptId }]), USER_SOURCE) first.agent.session.append('user/message', createUserMessage({ content: [{ type: 'text', text: 'extension event' }], source: { kind: 'user', rpcId: 1 } as never, @@ -366,9 +360,9 @@ describe('Session file uploads', () => { it('deduplicates a retried rpcId already present in the Agent inbox', async () => { const { controller, agent, followup } = await uploadHarness() const request = promptRequest([{ type: 'text', text: 'once' }]) - await controller.prompt(request, USER_SOURCE, SIGNAL) + await controller.prompt(request, USER_SOURCE) agent.inbox.append('next-turn', followup.mock.calls[0]?.[0] as UserMessage) - await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).toHaveBeenCalledOnce() }) @@ -385,7 +379,7 @@ describe('Session file uploads', () => { source: { kind: 'user', rpcId: request.requestId }, }), { surfaceOp: 'append' }) - await expect(controller.prompt(request, USER_SOURCE, SIGNAL)).resolves.toEqual({ accepted: true }) + await expect(controller.prompt(request, USER_SOURCE)).resolves.toEqual({ accepted: true }) expect(followup).not.toHaveBeenCalled() }) @@ -395,7 +389,7 @@ describe('Session file uploads', () => { saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt(promptRequest([ { type: 'image', mediaType: 'image/png', data: 'AAAA' }, - ]), USER_SOURCE, SIGNAL) + ]), USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) await disposeAgent() admitted.resolve([{ @@ -407,7 +401,7 @@ describe('Session file uploads', () => { it('rejects when a previously bound receipt retires during image admission', async () => { const { controller, uploads, agent, saveImages } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const admitted = Promise.withResolvers() saveImages.mockReturnValueOnce(admitted.promise) const prompting = controller.prompt({ @@ -416,7 +410,7 @@ describe('Session file uploads', () => { { type: 'image', mediaType: 'image/png', data: 'AAAA' }, ]), requestId: 'req-2' as SessionRequestId, - }, USER_SOURCE, SIGNAL) + }, USER_SOURCE) await vi.waitFor(() => { expect(saveImages).toHaveBeenCalledOnce() }) uploads.retirePrompt(agent, 'req-1') admitted.resolve([{ @@ -430,12 +424,12 @@ describe('Session file uploads', () => { it('keeps the prior receipt binding when a later prompt attempt fails', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt({ ...promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), requestId: 'req-2' as SessionRequestId, - }, USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' }) + }, USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toBeUndefined() }) @@ -446,7 +440,7 @@ describe('Session file uploads', () => { followup.mockImplementationOnce(() => { throw new Error('busy') }) await expect(controller.prompt(promptRequest([ { type: 'file', receiptId: receipt.receiptId }, - ]), USER_SOURCE, SIGNAL)).rejects.toMatchObject({ code: 'session/agent-busy' }) + ]), USER_SOURCE)).rejects.toMatchObject({ code: 'session/agent-busy' }) uploads.retirePrompt(agent, 'req-1') expect(uploads.resolve(agent, receipt.receiptId)).toEqual(receipt.file) }) @@ -454,7 +448,7 @@ describe('Session file uploads', () => { it('retires a prompt-bound receipt when its queued occurrence is removed', async () => { const { controller, uploads, agent, followup } = await uploadHarness() const receipt = await uploads.upload(agent, { data: 'AAAA' }, new AbortController().signal) - await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE, SIGNAL) + await controller.prompt(promptRequest([{ type: 'file', receiptId: receipt.receiptId }]), USER_SOURCE) const queued = followup.mock.calls[0]?.[0] as UserMessage agent.inbox.append('next-turn', queued) expect(await controller.updateQueue({ @@ -475,7 +469,7 @@ describe('Session file uploads', () => { await controller.prompt(promptRequest([ { type: 'file', receiptId: first.receiptId }, { type: 'file', receiptId: second.receiptId }, - ]), USER_SOURCE, SIGNAL) + ]), USER_SOURCE) const message = followup.mock.calls[0]?.[0] as UserMessage expect(message.content).toEqual([ diff --git a/packages/api/session-controller/tests/prompt-admission.host.spec.ts b/packages/api/session-controller/tests/prompt-admission.host.spec.ts deleted file mode 100644 index a61dec9b80..0000000000 --- a/packages/api/session-controller/tests/prompt-admission.host.spec.ts +++ /dev/null @@ -1,217 +0,0 @@ -/** - * Session prompts through the Agent controller's admission stage: how the - * `session/prompt` Remote reports a held, refused, or failed admission to the - * client. - */ - -import { describe, expect, it, onTestFinished, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { createInboxStub } from '@deepseek-ai/dsh-agent-loop-testkit' -import SessionStore from '@deepseek-ai/dsh-session' -import type { UserMessage } from '@deepseek-ai/dsh-session' -import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' -import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' -import { createSessionTestRemote } from './test-remote.ts' - -let nextRequestId = 1 - -async function harness(options: { readonly refusal?: Error } = {}) { - const ctx = new Context() - onTestFinished(() => ctx.fiber.dispose()) - await ctx.plugin(SessionStore) - await ctx.plugin(SessionProjectionRegistry) - await ctx.plugin(AgentRegistry) - const session = ctx.sessions.create() - const inbox = createInboxStub() - const deliver = (target: 'next-turn' | 'next-step', message: UserMessage): void => { - if (options.refusal !== undefined) throw options.refusal - inbox.append(target, message) - } - const agent: Agent = { - id: session.id, - options: {}, - session, - inbox, - status: 'idle', - ctx, - send: () => {}, - followup: (message) => { deliver('next-turn', message) }, - steer: (message) => { deliver('next-step', message) }, - inject: () => {}, - cancel: () => {}, - runMaintenance: task => task(new AbortController().signal), - whenIdle: () => Promise.resolve(), - } - const unregister = await ctx.agents.register(agent) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), - cwd: '/tmp', - }) - const prompt = (text: string, mode: SessionPromptRequest['mode'] = 'queue', requestId?: SessionRequestId) => ({ - requestId: requestId ?? `admission-${String(nextRequestId++)}` as SessionRequestId, - sessionId: session.id, - mode, - content: [{ type: 'text' as const, text }], - }) - return { ctx, agent, inbox, remote, prompt, unregister } -} - -function textsOf(messages: readonly UserMessage[]): string[] { - return messages.map(message => message.content.map(block => block.type === 'text' ? block.text : '').join('')) -} - -describe('Session prompt admission', () => { - it('admits a queued prompt into the next turn and a steered one into the next step', async () => { - const { ctx, agent, inbox, remote, prompt } = await harness() - const admitted: Agent[] = [] - ctx.on('agent-controller/admission', async ({ agent: subject }, next) => { - admitted.push(subject) - return await next() - }) - - expect(await remote.prompt(prompt('later'))).toEqual({ ok: true, value: { accepted: true } }) - expect(await remote.prompt(prompt('now', 'steer'))).toEqual({ ok: true, value: { accepted: true } }) - expect(admitted).toEqual([agent, agent]) - expect(textsOf(inbox.nextTurn)).toEqual(['later']) - expect(textsOf(inbox.nextStep)).toEqual(['now']) - }) - - it('keeps the prompt call open while a listener holds it', async () => { - const { ctx, inbox, remote, prompt } = await harness() - const release = Promise.withResolvers() - ctx.on('agent-controller/admission', async (_admission, next) => { - await release.promise - return await next() - }) - - let settled = false - const pending = remote.prompt(prompt('held')).finally(() => { settled = true }) - await new Promise((resolve) => { setImmediate(resolve) }) - expect(settled).toBe(false) - expect(inbox.nextTurn).toEqual([]) - release.resolve(undefined) - expect(await pending).toEqual({ ok: true, value: { accepted: true } }) - expect(textsOf(inbox.nextTurn)).toEqual(['held']) - }) - - it('returns a refusal with its reason and whether its plugin presents it, and keeps the inbox untouched', async () => { - const { ctx, inbox, remote, prompt } = await harness() - let presented = true - ctx.on('agent-controller/admission', () => Promise.resolve({ - kind: 'reject' as const, reason: 'fixture/worktree-missing', message: 'The worktree was removed.', presented, - })) - - expect(await remote.prompt(prompt('refused'))).toMatchObject({ - ok: false, - error: { - code: 'session/prompt-rejected', - message: 'The worktree was removed.', - details: { reason: 'fixture/worktree-missing', presented: true }, - }, - }) - presented = false - expect(await remote.prompt(prompt('refused again'))).toMatchObject({ - ok: false, - error: { code: 'session/prompt-rejected', details: { reason: 'fixture/worktree-missing', presented: false } }, - }) - expect(inbox.nextTurn).toEqual([]) - }) - - it('reports a failed listener as a refusal the client explains', async () => { - const { ctx, inbox, remote, prompt } = await harness() - ctx.on('agent-controller/admission', () => Promise.reject(new Error('listener broke'))) - - const result = await remote.prompt(prompt('broken')) - expect(result).toMatchObject({ - ok: false, - error: { code: 'session/prompt-rejected', details: { reason: 'agent-controller/admission-failed', presented: false } }, - }) - expect(result.ok ? '' : result.error.message).toContain('listener broke') - expect(inbox.nextTurn).toEqual([]) - }) - - it('cancels a held prompt whose client went away', async () => { - const { ctx, inbox, remote, prompt } = await harness() - ctx.on('agent-controller/admission', async ({ signal }, next) => { - await new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { reject(signal.reason as Error) }, { once: true }) - }) - return await next() - }) - - const client = new AbortController() - const pending = remote.prompt(prompt('abandoned'), client.signal) - await new Promise((resolve) => { setImmediate(resolve) }) - client.abort(new Error('client left')) - expect(await pending).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) - expect(inbox.nextTurn).toEqual([]) - }) - - it('reports a Session whose Agent was disposed while its prompt was held as not found', async () => { - const { ctx, agent, remote, prompt, unregister } = await harness() - const release = Promise.withResolvers() - ctx.on('agent-controller/admission', async (_admission, next) => { - await release.promise - return await next() - }) - - const pending = remote.prompt(prompt('orphaned')) - await new Promise((resolve) => { setImmediate(resolve) }) - await unregister() - release.resolve(undefined) - expect(await pending).toMatchObject({ - ok: false, - error: { code: 'session/not-found', details: { sessionId: agent.id } }, - }) - }) - - it('maps a refusal from another copy of the agent-controller module by its code', async () => { - const { ctx, agent, remote, prompt } = await harness() - // Another copy of the module raises the same fields from its own class. - const refusal = (reason: string): Error => Object.assign(new Error('The worktree was removed.'), { - name: 'AgentAdmissionRejected', code: 'AGENT_ADMISSION_REJECTED', reason, presented: true, - }) - vi.spyOn(ctx.agentController, 'deliver') - .mockRejectedValueOnce(refusal('fixture/worktree-missing')) - .mockRejectedValueOnce(refusal('agent-controller/agent-gone')) - - expect(await remote.prompt(prompt('refused'))).toMatchObject({ - ok: false, - error: { - code: 'session/prompt-rejected', - message: 'The worktree was removed.', - details: { reason: 'fixture/worktree-missing', presented: true }, - }, - }) - expect(await remote.prompt(prompt('orphaned'))).toMatchObject({ - ok: false, - error: { code: 'session/not-found', details: { sessionId: agent.id } }, - }) - }) - - it('acknowledges a repeated request without admitting it again', async () => { - const { ctx, inbox, remote, prompt } = await harness() - let admissions = 0 - ctx.on('agent-controller/admission', async (_admission, next) => { - admissions += 1 - return await next() - }) - - const first = prompt('once') - expect(await remote.prompt(first)).toEqual({ ok: true, value: { accepted: true } }) - expect(await remote.prompt(prompt('once', 'queue', first.requestId))).toEqual({ ok: true, value: { accepted: true } }) - expect(admissions).toBe(1) - expect(textsOf(inbox.nextTurn)).toEqual(['once']) - }) - - it('reports the Agent refusing to queue a prompt as busy', async () => { - const { remote, prompt } = await harness({ refusal: new Error('inbox closed') }) - - expect(await remote.prompt(prompt('rejected'))).toMatchObject({ - ok: false, - error: { code: 'session/agent-busy', details: { reason: 'Error: inbox closed' } }, - }) - }) -}) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts index f6e4600063..c5619d59aa 100644 --- a/packages/api/session-controller/tests/session-models.host.spec.ts +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -137,46 +137,6 @@ async function harness(logged?: { return { ctx, agent, sessionId: session.id } } -/** An image store whose saved attachments are one-pixel PNGs. */ -function provideImageStore(ctx: Context): void { - const attachments = { - imageLimits: { - maxImageBytes: 4, - maxImagesPerMessage: 2, - maxMessageImageBytes: 4, - maxImagePixels: 4, - maxImageDimension: 2000, - mediaTypes: ['image/png'], - }, - validateImage: () => Promise.resolve(), - saveImage: (input: { data: Uint8Array; mediaType: 'image/png' }) => Promise.resolve({ - attachmentId: `att-${String(input.data[0])}`, mediaType: input.mediaType, bytes: input.data.byteLength, width: 1, height: 1, - }), - } - ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) -} - -/** A queued prompt with its text, after a one-pixel image when `withImage` is set. */ -function imagePrompt(sessionId: SessionId, text: string, withImage: boolean): SessionPromptRequest { - return promptRequest({ - sessionId, - mode: 'queue', - content: [ - ...withImage ? [{ type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' }] : [], - { type: 'text' as const, text }, - ], - }) -} - -function textOf(message: UserMessage): string { - return message.content.map(block => block.type === 'text' ? block.text : '').join('') -} - -/** Let pending prompts run as far as they can. */ -async function settle(): Promise { - await new Promise((resolve) => { setImmediate(resolve) }) -} - function expectValue(result: { ok: true; value: T } | { ok: false }): T { if (!result.ok) throw new Error('expected successful response') return result.value @@ -353,110 +313,6 @@ describe('Web session model selection', () => { await ctx.fiber.dispose() }) - it('admits image prompts in arrival order behind an image prompt that a listener holds', async () => { - const { ctx, agent, sessionId } = await harness() - provideImageStore(ctx) - const followed: string[] = [] - Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } }) - const release = Promise.withResolvers() - ctx.on('agent-controller/admission', async ({ message }, next) => { - if (textOf(message) === 'held') await release.promise - return await next() - }) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - cwd: '/tmp', - }) - - const prompts = [ - remote.prompt(imagePrompt(sessionId, 'held', true)), - remote.prompt(imagePrompt(sessionId, 'second image', true)), - remote.prompt(imagePrompt(sessionId, 'third text', false)), - ] - await settle() - expect(followed).toEqual([]) - release.resolve(undefined) - expect((await Promise.all(prompts)).map(result => result.ok)).toEqual([true, true, true]) - expect(followed).toEqual(['held', 'second image', 'third text']) - await ctx.fiber.dispose() - }) - - it('keeps a model selection behind the image prompt being admitted but not behind one still queued', async () => { - const { ctx, agent, sessionId } = await harness() - registerTextOnly(ctx) - provideImageStore(ctx) - const events: string[] = [] - Object.assign(agent, { followup: (message: UserMessage) => { events.push(`admitted ${textOf(message)}`) } }) - const releases = new Map([['held image', Promise.withResolvers()], ['held text', Promise.withResolvers()]]) - ctx.on('agent-controller/admission', async ({ message }, next) => { - await releases.get(textOf(message))?.promise - return await next() - }) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - cwd: '/tmp', - }) - const select = () => remote.selectModel(request({ sessionId, provider: 'text-only', model: 'plain' })) - .then((result) => { events.push('selected'); return result }) - - // A held image prompt was checked against the current model, so the selection waits for its append. - const heldImage = remote.prompt(imagePrompt(sessionId, 'held image', true)) - await settle() - const first = select() - await settle() - expect(events).toEqual([]) - releases.get('held image')?.resolve(undefined) - expect((await heldImage).ok).toBe(true) - expect((await first).ok).toBe(true) - expect(events).toEqual(['admitted held image', 'selected']) - - // An image prompt queued behind a held text prompt has not been checked yet, so the selection does not wait for it. - const heldText = remote.prompt(imagePrompt(sessionId, 'held text', false)) - const queuedImage = remote.prompt(imagePrompt(sessionId, 'queued image', true)) - await settle() - expect((await select()).ok).toBe(true) - expect(events).toEqual(['admitted held image', 'selected', 'selected']) - releases.get('held text')?.resolve(undefined) - expect((await heldText).ok).toBe(true) - expect(await queuedImage).toMatchObject({ - ok: false, error: { code: 'session/attachment-invalid', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, - }) - expect(events).toEqual(['admitted held image', 'selected', 'selected', 'admitted held text']) - await ctx.fiber.dispose() - }) - - it('passes the image admission turn on when the sender of a waiting image prompt leaves', async () => { - const { ctx, agent, sessionId } = await harness() - provideImageStore(ctx) - const followed: string[] = [] - Object.assign(agent, { followup: (message: UserMessage) => { followed.push(textOf(message)) } }) - const listing = Promise.withResolvers() - ctx.llm.registerAdapter(['gated'], new class extends CatalogAdapter { - override listModels(): Promise { - return listing.promise - } - }('Gated', [])) - const remote = await createSessionTestRemote(ctx, { - defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), - cwd: '/tmp', - }) - - // The selection holds the image admission turn while the provider lists its models. - const selection = remote.selectModel(request({ sessionId, provider: 'gated', model: 'slow' })) - await settle() - const sender = new AbortController() - const leaving = remote.prompt(imagePrompt(sessionId, 'left', true), sender.signal) - await settle() - sender.abort(new Error('client left')) - expect(await leaving).toMatchObject({ ok: false, error: { code: 'gateway/cancelled' } }) - - listing.resolve([{ provider: 'gated', id: 'slow', name: 'Slow' }]) - expect((await selection).ok).toBe(true) - expect((await remote.prompt(imagePrompt(sessionId, 'after', true))).ok).toBe(true) - expect(followed).toEqual(['after']) - await ctx.fiber.dispose() - }) - it('authorizes attachment bytes only when the session event stream references the id', async () => { const { ctx, agent, sessionId } = await harness() const ref = { diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 53cae34ea2..114e15734d 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -31,7 +31,6 @@ import { remoteErrorOf, type RemoteResult, } from '@deepseek-ai/dsh-typert-protocol' -import AgentController from '@deepseek-ai/dsh-agent-controller' import RemoteAccessService from '@deepseek-ai/dsh-api-remote-access' import SessionController from '../src/index.ts' import type { @@ -294,7 +293,6 @@ async function installControllers( } as never) } installSessionReadTestServices(ctx) - if (ctx.get('agentController') === undefined) new AgentController(ctx) if (ctx.get('remoteAccess') === undefined) new RemoteAccessService(ctx) const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) class TestController extends SessionController { diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json index 835f378507..8f90ca7a6a 100644 --- a/packages/api/session-controller/tsconfig.host.json +++ b/packages/api/session-controller/tsconfig.host.json @@ -32,9 +32,6 @@ { "path": "../../core/agent" }, - { - "path": "../../core/agent-controller" - }, { "path": "../../core/agent-default-model" }, diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index f2b0b061ca..c087b81f23 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -82,11 +82,6 @@ - id: agent name: '@deepseek-ai/dsh-agent' - # Delivers messages from outside an Agent into its inbox after the admission - # stage; entry points such as the Web Session Remote deliver through it. - - id: agent-controller - name: '@deepseek-ai/dsh-agent-controller' - - id: plugin-package-inventory-deepseek name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index e306142030..934043be6d 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -37,7 +37,6 @@ "@deepseek-ai/dsh-hmr": "workspace:*", "@deepseek-ai/cordis-plugin-timer": "workspace:~", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-agent-controller": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-attachment-local": "workspace:*", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 6b8a1b8d21..85d5d69354 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -115,7 +115,7 @@ One UI feature = one plugin package (`src/client/` browser half). A multi-domain ## Styling and localization -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md). +[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. Design judgment beyond those rules — feedback surfaces, loading states, overlay safety, platform window adaptation — follows [dsh-client-ui-ux](../../.agents/skills/dsh-client-ui-ux/SKILL.md); responsive layout and telling a mobile or touch client from a desktop one follow [dsh-responsive-ui](../../.agents/skills/dsh-responsive-ui/SKILL.md). Menus use `Menu` or `MenuSurface`, including custom listboxes, except the explicitly listed schedule containers. Preserve the shared transparency and blur ([rules](../../docs/web-styling.md#component-rules)). diff --git a/packages/client/locale/src/client/LanguageRow.module.css b/packages/client/locale/src/client/LanguageRow.module.css index af02724b4f..3d78e59775 100644 --- a/packages/client/locale/src/client/LanguageRow.module.css +++ b/packages/client/locale/src/client/LanguageRow.module.css @@ -42,8 +42,10 @@ cursor: pointer; } -.selector:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .selector:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .chevron { diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index 1046c1daea..ceef370d58 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -15,8 +15,8 @@ en: d152484eb41ac6b4 zh: b3ad7867f23dad1c /deepseek-ai-dsh-client-ui-agent-preset/use-this-package: - en: e015215c32d78b52 - zh: e8a8029903de7515 + en: 475fae2164ced556 + zh: 4d0c729ffc210a83 /deepseek-ai-dsh-client-ui-agent-preset/understand-the-implementation: en: d7a38ad9e5497789 zh: 5f5595ddd90fa6bd diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index b1040b645b..0f92e5ddde 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -25,7 +25,7 @@ Choose Agent presets and the new-task default in Web, read what each mode does a ## Use this package -Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. +Settings shows the built-in and custom card groups with default highlighting and card-body selection; a group without presets is omitted, except the custom group, which keeps its Creator entry on screen. Coarse-pointer devices show complete card descriptions; other devices clamp descriptions to four lines with the full text available on hover. Every card offers “View configuration”, which opens the preset's declared plugin list as read-only YAML in the Loader's own dialect (`!!js` conditions included); a failed preset stays readable because its diagnostic points into that YAML. Escape closes only the viewer and returns focus to its card; leaving Settings clears the viewer, and a late read does not reopen it. The page edits nothing: the Creator entry starts a Creator-mode task that authors or overrides a preset as a bundle, offered while the `cordis` preset is on the roster and a conversation flow exists. With Coding Tools off in General Settings, the new-session picker and Settings roster offer Standard, Creator, and custom presets; turning them on also offers PTC and Minimal. Default selection and the Creator entry remain available in both states. Choosing a healthy default also synchronizes the blank session on the current new-task surface. Creator starts a new task using the `cordis` preset. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index b9b43f391d..41b820ad95 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -25,7 +25,7 @@ kind: "package-reference" ## 使用此包 -设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 +设置页显示内置与自定义卡片分组、默认项高亮和点击卡片选择;没有 preset 的分组不显示,但自定义分组会保留其创造入口。粗指针设备完整显示卡片描述;其他设备最多显示四行描述,悬停可查看全文。每张卡片都提供「查看配置」,以只读 YAML 打开该 preset 声明的插件列表,使用 Loader 自己的方言(含 `!!js` 条件);加载失败的 preset 同样可读,因为其诊断信息正指向这份 YAML。Escape 只关闭查看器并将焦点还给卡片;离开设置页会清空查看器,迟到的读取结果不会重新打开它。本页不编辑任何内容:创造入口启动一个创造模式任务,以 bundle 形式创建或覆盖 preset;当 `cordis` preset 在列表中且存在会话流程时提供。 关闭通用设置中的代码工作工具后,新会话选择器和设置页列表提供标准、创造和自定义预设;开启后还提供 PTC 和极简模式。两种状态下均保留默认值选择和创造入口。选择健康定义作为默认值也会同步当前新任务页面的空白会话。Creator 入口开启一个使用 `cordis` preset 的新任务。 diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 04f27d122d..2de883fe7e 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -34,11 +34,16 @@ cursor: pointer; } -.seat:not(:disabled):hover, .seat[aria-expanded='true'] { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .seat:not(:disabled):hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + .seat:disabled { cursor: default; color: var(--dsw-alias-label-quaternary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 32ad216063..96af208257 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -61,8 +61,10 @@ } -.card:hover:not(.cardActive) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .card:hover:not(.cardActive) { + background: var(--dsw-alias-interactive-bg-hover); + } } /* The default preset reads as selected exactly like the Appearance cubes: @@ -81,8 +83,10 @@ border-color: var(--dsw-alias-state-error-primary); } -.cardBroken:hover { - border-color: var(--dsw-alias-state-error-primary); +@media (hover: hover) { + .cardBroken:hover { + border-color: var(--dsw-alias-state-error-primary); + } } .brokenBadge { @@ -127,11 +131,16 @@ /* Focusing the card reveals it too: a broken card keeps its place in the tab order (`aria-disabled`, not `disabled`), so this is the keyboard path to a reason the card face no longer shows. */ -.brokenBadge:hover .brokenTip, .cardMain:focus-visible .brokenTip { opacity: 1; } +@media (hover: hover) { + .brokenBadge:hover .brokenTip { + opacity: 1; + } +} + .cardMain[aria-disabled='true'] { cursor: default; } @@ -155,6 +164,18 @@ white-space: nowrap; } +@media (hover: none) { + .cardBrokenReason { + position: static; + display: block; + width: auto; + height: auto; + overflow: visible; + clip: auto; + white-space: normal; + } +} + /* The card body is the control that picks the preset. */ .cardMain { flex: 1; @@ -230,6 +251,12 @@ overflow-wrap: anywhere; } +@media (pointer: coarse) { + .cardDesc { + -webkit-line-clamp: unset; + } +} + .cardId { flex-shrink: 0; max-width: 35%; @@ -267,11 +294,16 @@ color: var(--dsw-alias-label-tertiary); } -.helpButton:hover, .helpButton:focus-visible { color: var(--dsw-alias-label-primary); } +@media (hover: hover) { + .helpButton:hover { + color: var(--dsw-alias-label-primary); + } +} + /* Icon-only actions: the label rides `data-tip` so the row stays quiet until someone reaches for it. */ .iconButton { @@ -287,9 +319,11 @@ align-items: center; } -.iconButton:hover { - background: var(--dsw-alias-bg-layer-1); - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .iconButton:hover { + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + } } .iconButton:focus-visible { @@ -315,11 +349,16 @@ transition: opacity .12s; } -.iconButton:hover::after, .iconButton:focus-visible::after { opacity: 1; } +@media (hover: hover) { + .iconButton:hover::after { + opacity: 1; + } +} + .dialog { width: min(720px, 100%); } @@ -370,8 +409,10 @@ cursor: pointer; } -.creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .creatorButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } .creatorButton:disabled { diff --git a/packages/client/ui-agent-preset/tests/section.client.spec.tsx b/packages/client/ui-agent-preset/tests/section.client.spec.tsx index 78a4f599e3..1ea42a6af1 100644 --- a/packages/client/ui-agent-preset/tests/section.client.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.client.spec.tsx @@ -287,7 +287,7 @@ it.each([false, true])('only offers a description tooltip when the card clips it const disconnect = vi.spyOn(ResizeObserver.prototype, 'disconnect') vi.useFakeTimers() view({ rows: [{ id: 'mine', isDefault: false, description: 'Preset description' }] }) - fireEvent.mouseEnter(screen.getByText('Preset description')) + fireEvent.pointerEnter(screen.getByText('Preset description')) act(() => { vi.advanceTimersByTime(400) }) expect(screen.queryByRole('tooltip')?.textContent ?? null).toBe(overflow ? 'Preset description' : null) cleanup() diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index 611dd4eaa5..879eec2cc2 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -60,8 +60,10 @@ padding: 14px 16px; } -.reject:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); - --dsh-button-outline-color: transparent; +@media (hover: hover) { + .reject:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + --dsh-button-outline-color: transparent; + } } diff --git a/packages/client/ui-attachment/src/AttachmentRail.module.css b/packages/client/ui-attachment/src/AttachmentRail.module.css index f2bacc5cc8..c405b2ed7d 100644 --- a/packages/client/ui-attachment/src/AttachmentRail.module.css +++ b/packages/client/ui-attachment/src/AttachmentRail.module.css @@ -65,13 +65,18 @@ transition: opacity 0.2s ease-in-out; } -.item:hover .remove, .remove:focus-visible { opacity: 1; } +@media (hover: hover) { + .item:hover .remove { + opacity: 1; + } +} + /* Touch surfaces have no hover to reveal the control. */ -@media (pointer: coarse) { +@media (hover: none), (pointer: coarse) { .remove { opacity: 1; } @@ -102,8 +107,10 @@ transform: translateY(-50%); } -.arrow:hover { - background: var(--dsw-alias-interactive-bg-hover-solid); +@media (hover: hover) { + .arrow:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + } } .arrowLeft { diff --git a/packages/client/ui-attachment/src/FileCard.module.css b/packages/client/ui-attachment/src/FileCard.module.css index 20d81a66d6..739ed5f8f0 100644 --- a/packages/client/ui-attachment/src/FileCard.module.css +++ b/packages/client/ui-attachment/src/FileCard.module.css @@ -101,12 +101,17 @@ transition: opacity 0.2s ease-in-out; } -.card:hover .remove, .remove:focus-visible { opacity: 1; } -@media (pointer: coarse) { +@media (hover: hover) { + .card:hover .remove { + opacity: 1; + } +} + +@media (hover: none), (pointer: coarse) { .remove { opacity: 1; } @@ -118,11 +123,16 @@ } } -.card:hover .name, .card:focus-within .name { padding-right: 18px; } +@media (hover: hover) { + .card:hover .name { + padding-right: 18px; + } +} + .removeFailed { background: var(--dsw-alias-state-error-primary, #d54941); color: #fff; @@ -158,7 +168,7 @@ to { transform: translateX(220%); } } -@media (pointer: coarse) { +@media (hover: none), (pointer: coarse) { .remove { opacity: 1; } diff --git a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css index 1c5028503a..adb40af60f 100644 --- a/packages/client/ui-attachment/src/client/ComposerAttachments.module.css +++ b/packages/client/ui-attachment/src/client/ComposerAttachments.module.css @@ -50,12 +50,17 @@ transition: opacity 0.2s ease-in-out; } -.imageItem:hover .remove, .remove:focus-visible { opacity: 1; } -@media (pointer: coarse) { +@media (hover: hover) { + .imageItem:hover .remove { + opacity: 1; + } +} + +@media (hover: none), (pointer: coarse) { .remove { opacity: 1; } diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index ad049cf8f1..abaab0beb5 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -27,8 +27,8 @@ en: bb20da372b144302 zh: 75a8c5ab7ec8d665 /deepseek-ai-dsh-client-ui-chat/completed-turn-footer: - en: b4cd36e7ab91281e - zh: 8ccf412b9dde61d5 + en: 382821ca3ffa60dd + zh: 9840ffa07c13082a /deepseek-ai-dsh-client-ui-chat/turn-process-folding: en: b0d806604bc17006 zh: 18dabe5dc2d0d419 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index f52fe1e4ba..ed77f4074f 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -79,7 +79,7 @@ Preference menus restore focus to their trigger without scrolling before publish Artifact extensions can subscribe to one Turn and Node kind through `ChatNodeStore.turnDataSource`. The source includes hidden Nodes and exposes their business data in anchor order. Membership updates incrementally; only observed collections materialize ordered arrays, and unrelated Turns or kinds do not notify them. -The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible. +The completed-turn action footer follows the recorded Turn end. Its action row starts 20px below preceding prose or extension content. Actions remain visible only on the latest Turn when its final visible content is a reply; other endings and historical Turns reveal actions on hover or keyboard focus. Devices without hover keep actions visible. An unavailable branch action opens its explanation on click or tap without creating a branch. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 7b00a3c9fa..ea539ee583 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -79,7 +79,7 @@ Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node, 产物扩展可通过 `ChatNodeStore.turnDataSource` 订阅一个 Turn 中指定类型的节点数据。来源包含隐藏节点,并按锚点顺序提供其业务数据。成员关系增量更新;只有被订阅的集合才生成有序数组,其他 Turn 或类型的更新不通知该集合。 -已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。 +已完成轮次的操作页脚位于记录的轮次结束之后。操作行与前方正文或扩展内容相隔 20px。只有最新轮次且最后可见内容为回复时,操作常显;其他结尾及历史轮次在悬停或键盘聚焦时显示操作。不支持悬停的设备始终显示操作。分支操作不可用时,鼠标或触摸点击可查看原因,不会创建分支。 ----- diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css index 214b3a251c..979cef25bb 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -20,8 +20,10 @@ transition: color 100ms ease; } -.title:hover { - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .title:hover { + color: var(--dsw-alias-label-secondary); + } } /* The leading box and its glyph ride the body font-size delta so the icon @@ -65,14 +67,26 @@ opacity: 0; } -.title:is(:hover, :focus-visible) .activityIcon { +.title:focus-visible .activityIcon { opacity: 0; } -.title:is(:hover, :focus-visible) .chevron { +@media (hover: hover) { + .title:hover .activityIcon { + opacity: 0; + } +} + +.title:focus-visible .chevron { opacity: 1; } +@media (hover: hover) { + .title:hover .chevron { + opacity: 1; + } +} + .title[aria-expanded="true"] .activityIcon { opacity: 0; } diff --git a/packages/client/ui-chat/src/client/chat/ChatView.module.css b/packages/client/ui-chat/src/client/chat/ChatView.module.css index 74cc41351e..8c5bb1171a 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.module.css +++ b/packages/client/ui-chat/src/client/chat/ChatView.module.css @@ -187,7 +187,7 @@ @media (prefers-reduced-motion: no-preference) { :where(.column > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]), - [data-chat-group-key] + [data-chat-group-key] )) ~ .running { animation: running-follows-output 110ms cubic-bezier(0.4, 0, 0.2, 1); } @@ -199,6 +199,7 @@ turning a switch on completes a running move at once. The structural rules above are wrapped in :where() so these rules outrank them. An input fold runs both moves at zero duration. */ + .column[data-input-fold] .running { animation-duration: 0s; } @@ -207,9 +208,10 @@ status keeps its on-screen position, so the first-output slide runs at zero duration. Admitted steering still passes its pending echo below the status, so the steering entrance keeps its duration. */ + .column[data-chat-tail-pinned] > :global([data-slot="conversation.chat.flow"]) > :nth-last-child(2 of :not([hidden]):not([data-pending-steering]):not(.flowItem:is(:empty, :has(> [data-slot="conversation.chat.node"]:empty)))):is( .flowItem:not([data-chat-flow-kind="user"]):not([data-chat-flow-kind="steering"]):not([data-chat-flow-kind="turn-trigger"]), - [data-chat-group-key] + [data-chat-group-key] ) ~ .running { animation-duration: 0s; } @@ -415,8 +417,10 @@ pointer-events: auto; } -.toBottom:hover { - background: var(--dsw-alias-button-floating-hover); +@media (hover: hover) { + .toBottom:hover { + background: var(--dsw-alias-button-floating-hover); + } } /* Host open-path refusal: same dialog family as the workspace folder error. */ diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css index 29c1c9e713..9c211ea186 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.module.css @@ -86,9 +86,11 @@ height: calc(17px + var(--dsh-content-font-delta, 0px)); } -.action:hover { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .action:hover { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + } } /* Unavailable stays focusable and hoverable so Tooltip can explain why. */ @@ -97,9 +99,11 @@ opacity: 0.4; } -.action[data-unavailable]:hover { - background: transparent; - color: var(--dsw-alias-label-tertiary); +@media (hover: hover) { + .action[data-unavailable]:hover { + background: transparent; + color: var(--dsw-alias-label-tertiary); + } } .visuallyHidden { diff --git a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx index fd2b674edd..bad505ff46 100644 --- a/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx +++ b/packages/client/ui-chat/src/client/chat/MessageIconActions.tsx @@ -89,7 +89,7 @@ export function MessageIconActions({ {extraActions} {onBranch !== undefined && ( - + {/* Native disabled buttons do not deliver the hover/focus events Tooltip needs. */} const view = render(thumbnail} />) const first = screen.getByRole('button', { name: 'first' }) diff --git a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx index 83f6ca6e09..e4b7891e30 100644 --- a/packages/client/ui-primitives/tests/tooltip.client.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.client.spec.tsx @@ -31,6 +31,8 @@ beforeEach(() => { automaticResize = true observers.length = 0 vi.stubGlobal('ResizeObserver', TooltipResizeObserver) + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + fireEvent.keyDown(document.body, { key: 'Tab' }) }) afterEach(() => { cleanup() @@ -39,6 +41,103 @@ afterEach(() => { }) describe('Tooltip', () => { + describe('touch interaction', () => { + beforeEach(() => { vi.useFakeTimers() }) + afterEach(() => { cleanup(); vi.useRealTimers() }) + + it('ignores emulated mouse entry on touch-only devices while preserving keyboard focus and connected mice', () => { + const media = vi.fn(() => ({ matches: false })) + vi.stubGlobal('matchMedia', media) + const view = render() + const anchor = screen.getByText('anchor') + fireEvent.pointerDown(anchor, { pointerType: 'touch' }) + fireEvent.click(anchor) + for (const label of ['Copied', 'Copy']) { + view.rerender() + fireEvent.pointerEnter(anchor, { pointerType: 'mouse' }) + expect(screen.queryByRole('tooltip')).toBeNull() + } + expect(media).toHaveBeenCalledWith('(any-hover: hover)') + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Copy') + fireEvent.blur(anchor) + media.mockReturnValue({ matches: true }) + fireEvent.pointerEnter(anchor, { pointerType: 'mouse' }) + expect(screen.getByRole('tooltip').textContent).toBe('Copy') + }) + + it('executes an action once without showing a tooltip for touch or compatibility mouse events', () => { + const clicked = vi.fn() + const label = vi.fn(() => 'Copy') + render() + const anchor = screen.getByText('anchor') + fireEvent.pointerEnter(anchor, { pointerType: 'touch' }) + fireEvent.pointerDown(anchor, { pointerType: 'touch' }) + fireEvent.mouseEnter(anchor) + fireEvent.focus(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(label).not.toHaveBeenCalled() + expect(screen.queryByRole('tooltip')).toBeNull() + fireEvent.pointerUp(anchor, { pointerType: 'touch' }) + fireEvent.pointerLeave(anchor, { pointerType: 'touch' }) + fireEvent.click(anchor) + fireEvent.mouseLeave(anchor) + fireEvent.mouseEnter(anchor) + act(() => { vi.advanceTimersByTime(500) }) + expect(clicked).toHaveBeenCalledOnce() + expect(label).not.toHaveBeenCalled() + expect(screen.queryByRole('tooltip')).toBeNull() + }) + + it.each([0, 500])('cancels an existing mouse hover on touch with a %i ms delay', (delayMs) => { + render() + const anchor = screen.getByText('anchor') + fireEvent.pointerEnter(anchor, { pointerType: 'mouse' }) + expect(screen.queryByRole('tooltip') !== null).toBe(delayMs === 0) + fireEvent.pointerDown(anchor, { pointerType: 'touch' }) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + + fireEvent.pointerLeave(anchor, { pointerType: 'touch' }) + fireEvent.pointerEnter(anchor, { pointerType: 'mouse' }) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.getByRole('tooltip').textContent).toBe('Copy') + fireEvent.pointerLeave(anchor, { pointerType: 'mouse' }) + fireEvent.keyDown(document.body, { key: 'Tab' }) + fireEvent.focus(anchor) + expect(screen.getByRole('tooltip').textContent).toBe('Copy') + }) + + it('opens an explanation only on tap and keeps it readable until another tap or outside press', () => { + render() + const anchor = screen.getByText('anchor') + const tap = () => { + fireEvent.pointerEnter(anchor, { pointerType: 'touch' }) + fireEvent.pointerDown(anchor, { pointerType: 'touch' }) + fireEvent.focus(anchor) + fireEvent.pointerUp(anchor, { pointerType: 'touch' }) + fireEvent.pointerLeave(anchor, { pointerType: 'touch' }) + fireEvent.click(anchor) + } + fireEvent.pointerEnter(anchor, { pointerType: 'touch' }) + act(() => { vi.advanceTimersByTime(500) }) + expect(screen.queryByRole('tooltip')).toBeNull() + tap() + const bubble = screen.getByRole('tooltip') + expect(bubble.textContent).toBe('Plugin information') + expect(anchor.getAttribute('aria-describedby')).toBe(bubble.id) + fireEvent.blur(anchor) + fireEvent.pointerDown(bubble, { pointerType: 'touch' }) + expect(screen.getByRole('tooltip')).toBe(bubble) + tap() + expect(screen.queryByRole('tooltip')).toBeNull() + tap() + fireEvent.pointerDown(document.body, { pointerType: 'touch' }) + expect(screen.queryByRole('tooltip')).toBeNull() + }) + }) + it('updates independent keycaps and the accessible combination while visible', () => { const view = render() fireEvent.focus(screen.getByText('anchor')) @@ -56,7 +155,7 @@ describe('Tooltip', () => { const measured = vi.spyOn(Element.prototype, 'getBoundingClientRect') const view = render() const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) const bubble = view.container.querySelector('[role="tooltip"]')! expect(measured.mock.contexts).toEqual([anchor]) expect(bubble.style.visibility).toBe('hidden') @@ -115,7 +214,7 @@ describe('Tooltip', () => { if (element === null) fireEvent(window, new Event('resize')) } const view = render() - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip')).toBeTruthy() view.unmount() expect(screen.queryByRole('tooltip')).toBeNull() @@ -133,7 +232,7 @@ describe('Tooltip', () => { , ) expect(label).not.toHaveBeenCalled() - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) act(() => { vi.advanceTimersByTime(499) }) expect(label).not.toHaveBeenCalled() act(() => { vi.advanceTimersByTime(1) }) @@ -153,16 +252,16 @@ describe('Tooltip', () => { , ) const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) act(() => { vi.advanceTimersByTime(499) }) expect(screen.queryByRole('tooltip')).toBeNull() - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) act(() => { vi.advanceTimersByTime(1) }) expect(screen.queryByRole('tooltip')).toBeNull() - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) act(() => { vi.advanceTimersByTime(500) }) expect(screen.getByRole('tooltip').textContent).toBe('Timing details') - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) fireEvent.focus(anchor) expect(screen.getByRole('tooltip').textContent).toBe('Timing details') } finally { @@ -193,7 +292,7 @@ describe('Tooltip', () => { expect(screen.getByRole('tooltip').textContent).toBe('Refresh') }) - it.each(['blur', 'click', 'mouseLeave', 'disabled', 'unmount'] as const)( + it.each(['blur', 'click', 'pointerLeave', 'disabled', 'unmount'] as const)( 'cancels a pending focus bubble on %s', (cancel) => { const view = render() const anchor = screen.getByText('anchor') @@ -222,7 +321,7 @@ describe('Tooltip', () => { render() const anchor = screen.getByText('anchor') const initialTimers = vi.getTimerCount() - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) act(() => { vi.advanceTimersByTime(499) }) expect(screen.queryByRole('tooltip')).toBeNull() fireEvent.focus(anchor) @@ -256,13 +355,13 @@ describe('Tooltip', () => { , ) const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) const bubble = screen.getByRole('tooltip') expect(bubble.textContent).toBe('Open sidebar') expect(bubble.getAttribute('data-side')).toBe('right') expect(bubble.style.left).toBe('12px') expect(bubble.style.top).toBe('12px') - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() }) @@ -296,7 +395,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-align')).toBe('end') expect(bubble.style.left).toBe('200px') @@ -311,7 +410,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip').style.maxWidth).toBe('min(360px, calc(100vw - 24px))') }) @@ -325,7 +424,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) // pos.x = 1000 (anchor center); measured right edge 1100 overflows the // 1024 viewport's 12px safe margin (limit 1012) by 88, so the clamp // shifts left to 912. @@ -345,7 +444,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip').style.left).toBe('912px') view.rerender( @@ -377,7 +476,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) // pos.x = 30 (anchor center); measured left edge -20 underflows the // 12px safe margin by 32, so the clamp shifts right to 62. expect(screen.getByRole('tooltip').style.left).toBe('62px') @@ -401,7 +500,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) const bubble = screen.getByRole('tooltip') // There is room above, so the requested side stands: the bubble's own // top sits at the anchor's top less the 8px gutter. @@ -423,7 +522,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-side')).toBe('top') expect(bubble.style.top).toBe('592px') @@ -440,7 +539,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-side')).toBe('bottom') expect(bubble.style.top).toBe('48px') @@ -454,7 +553,7 @@ describe('Tooltip', () => { const spy = placed(anchorBottom - 20, anchorBottom, 20) try { const view = render() - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) const bubble = screen.getByRole('tooltip') expect(bubble.getAttribute('data-side')).toBe(side) expect(bubble.style.top).toBe(`${side === 'bottom' ? anchorBottom + 4 : anchorBottom - 24}px`) @@ -474,7 +573,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('right') expect(screen.getByRole('tooltip').style.left).toBe('210px') } finally { @@ -499,7 +598,7 @@ describe('Tooltip', () => { const bubble = screen.getByRole('tooltip') expect(bubble.textContent).toBe('Plugin information') expect(anchor.getAttribute('aria-describedby')).toBe(`existing-description ${bubble.id}`) - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBe(bubble) fireEvent.pointerDown(bubble) @@ -519,9 +618,9 @@ describe('Tooltip', () => { ) const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.getByRole('tooltip').textContent).toBe('Plugin information') - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() fireEvent.keyDown(anchor, { key: 'Tab' }) fireEvent.focus(anchor) @@ -544,11 +643,11 @@ describe('Tooltip', () => { try { render() const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.queryByRole('tooltip')).toBeNull() fireEvent.click(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.keyDown(anchor, { key: 'Escape' }) act(() => { vi.advanceTimersByTime(300) }) @@ -571,7 +670,7 @@ describe('Tooltip', () => { fireEvent.click(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() view.rerender() - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() view.rerender() fireEvent.click(anchor) @@ -588,15 +687,15 @@ describe('Tooltip', () => { const anchor = screen.getByText('anchor') // Pointer click: browsers focus the button first, then deliver the click. fireEvent.focus(anchor) - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() fireEvent.click(anchor) expect(screen.queryByRole('tooltip')).toBeNull() // The retained focus alone must not resurrect it on mouse leave. - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() // A fresh hover shows the (possibly relabelled) bubble again. - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() }) @@ -620,24 +719,27 @@ describe('Tooltip', () => { }) it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { - const onMouseEnter = vi.fn() - const onMouseLeave = vi.fn() + const onPointerEnter = vi.fn() + const onPointerLeave = vi.fn() + const onPointerDown = vi.fn() const onClick = vi.fn() const onFocus = vi.fn() const onBlur = vi.fn() render( - + , ) const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) - fireEvent.mouseLeave(anchor) + fireEvent.pointerEnter(anchor) + fireEvent.pointerDown(anchor) + fireEvent.pointerLeave(anchor) fireEvent.click(anchor) fireEvent.focus(anchor) fireEvent.blur(anchor) - expect(onMouseEnter).toHaveBeenCalledOnce() - expect(onMouseLeave).toHaveBeenCalledOnce() + expect(onPointerEnter).toHaveBeenCalledOnce() + expect(onPointerLeave).toHaveBeenCalledOnce() + expect(onPointerDown).toHaveBeenCalledOnce() expect(onClick).toHaveBeenCalledOnce() expect(onFocus).toHaveBeenCalledOnce() expect(onBlur).toHaveBeenCalledOnce() @@ -650,7 +752,7 @@ describe('Tooltip', () => { , ) const anchor = screen.getByText('anchor') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.queryByRole('tooltip')).toBeNull() rerender( @@ -659,7 +761,7 @@ describe('Tooltip', () => { ) // Same DOM node: toggling disabled never remounted the anchor. expect(screen.getByText('anchor')).toBe(anchor) - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() }) @@ -672,14 +774,14 @@ describe('Tooltip', () => { const anchor = screen.getByText('anchor') // Focused AND hovered: leaving with the mouse drops the bubble at once. fireEvent.focus(anchor) - fireEvent.mouseEnter(anchor) - fireEvent.mouseLeave(anchor) + fireEvent.pointerEnter(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() // Re-entering shows it again; blurring while still hovered keeps it. - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) fireEvent.blur(anchor) expect(screen.getByRole('tooltip')).toBeTruthy() - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() }) @@ -693,7 +795,7 @@ describe('Tooltip', () => { ) expect(objectRef.current).toBe(screen.getByText('anchor')) // Tooltip's own positioning still works through the merged ref. - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip')).toBeTruthy() rerender( @@ -709,7 +811,7 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByText('anchor')) expect(screen.getByRole('tooltip')).toBeTruthy() // e.g. clicking a rail control expands the sidebar: no mouseleave fires. rerender( @@ -733,20 +835,20 @@ describe('Tooltip', () => { ) const anchor = screen.getByText('anchor') const badge = screen.getByTestId('badge') - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') // Entering the nested anchor withdraws the enclosing bubble instead of // stacking both; the enclosing anchor stays hovered, so nothing is lost. - fireEvent.mouseEnter(badge) + fireEvent.pointerEnter(badge) expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update — V1.2.3']) // Leaving the nested anchor for the enclosing one restores its bubble; // the pointer never left the enclosing anchor, so only the badge is left. - fireEvent.mouseLeave(badge, { relatedTarget: anchor }) + fireEvent.pointerLeave(badge, { relatedTarget: anchor }) expect(screen.getByRole('tooltip').textContent).toBe('Open sidebar') - fireEvent.mouseLeave(anchor) + fireEvent.pointerLeave(anchor) expect(screen.queryByRole('tooltip')).toBeNull() }) @@ -759,8 +861,8 @@ describe('Tooltip', () => { , ) - fireEvent.mouseEnter(screen.getByText('anchor')) - fireEvent.mouseEnter(screen.getByTestId('badge')) + fireEvent.pointerEnter(screen.getByText('anchor')) + fireEvent.pointerEnter(screen.getByTestId('badge')) expect(screen.getAllByRole('tooltip').map(bubble => bubble.textContent)).toEqual(['Update']) view.rerender( @@ -778,7 +880,7 @@ it('keeps the anchor in its clipping container and portals only the tooltip', () ) const anchor = screen.getByRole('button', { name: 'File action' }) - fireEvent.mouseEnter(anchor) + fireEvent.pointerEnter(anchor) const tooltip = screen.getByRole('tooltip') expect(tooltip.parentElement).toBe(document.body) expect(view.container.contains(anchor)).toBe(true) diff --git a/packages/client/ui-schedule/src/client/ClockPicker.module.css b/packages/client/ui-schedule/src/client/ClockPicker.module.css index b88077b773..b37001116d 100644 --- a/packages/client/ui-schedule/src/client/ClockPicker.module.css +++ b/packages/client/ui-schedule/src/client/ClockPicker.module.css @@ -31,8 +31,10 @@ outline: none; } -.option:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .option:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .option:focus-visible { diff --git a/packages/client/ui-schedule/src/client/DatePicker.module.css b/packages/client/ui-schedule/src/client/DatePicker.module.css index c63aac4c2e..a1290e2314 100644 --- a/packages/client/ui-schedule/src/client/DatePicker.module.css +++ b/packages/client/ui-schedule/src/client/DatePicker.module.css @@ -36,8 +36,10 @@ cursor: pointer; } -.nav:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .nav:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .nav:focus-visible { @@ -82,8 +84,10 @@ outline: none; } -.cell:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .cell:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .cell:focus-visible { diff --git a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css index d474d4b69d..d7ccf295b5 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCatalogAction.module.css @@ -22,12 +22,18 @@ cursor: pointer; } -.trigger:hover, .trigger:focus-visible { color: var(--dsw-alias-label-secondary); background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .trigger:hover { + color: var(--dsw-alias-label-secondary); + background: var(--dsw-alias-interactive-bg-hover); + } +} + .trigger svg { flex: none; } @@ -75,16 +81,20 @@ position: relative; } -.rowTask:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .rowTask:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .rowOverdue { background: var(--dsw-alias-state-warn-tertiary); } -.rowOverdue:hover { - background: var(--dsw-alias-state-warn-tertiary); +@media (hover: hover) { + .rowOverdue:hover { + background: var(--dsw-alias-state-warn-tertiary); + } } .body { @@ -173,12 +183,18 @@ cursor: pointer; } -.deleteButton:hover, .deleteButton:focus-visible { color: var(--dsw-alias-label-primary); background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .deleteButton:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); + } +} + .deleteButton:disabled { cursor: wait; } diff --git a/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css index a56588c1d4..9d1a8f5479 100644 --- a/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css +++ b/packages/client/ui-schedule/src/client/ScheduleCreateCard.module.css @@ -24,8 +24,10 @@ } /* Only a card whose whole row opens the task answers hover. */ -.card:has(.cardOpen):hover { - background: var(--card-hover); +@media (hover: hover) { + .card:has(.cardOpen):hover { + background: var(--card-hover); + } } .cardOpen { diff --git a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css index ebdb77b4b8..98284b3f1e 100644 --- a/packages/client/ui-schedule/src/client/TaskManagerPage.module.css +++ b/packages/client/ui-schedule/src/client/TaskManagerPage.module.css @@ -133,8 +133,10 @@ cursor: pointer; } -.filterTab:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .filterTab:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .filterTabActive { @@ -158,8 +160,10 @@ transition: border-color 0.15s; } -.searchField:hover { - border-color: var(--dsw-alias-border-l2); +@media (hover: hover) { + .searchField:hover { + border-color: var(--dsw-alias-border-l2); + } } .searchField:focus-within { @@ -247,8 +251,10 @@ white-space: normal; } -.row:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .row:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .rowGlyph { @@ -314,12 +320,22 @@ color: var(--dsw-alias-label-caption); } -.endedRow:hover .rowTitle, +@media (hover: hover) { + .endedRow:hover .rowTitle { + color: inherit; + } +} + .endedRow.selectedRow .rowTitle { color: inherit; } -.endedRow:hover .rowSummary, +@media (hover: hover) { + .endedRow:hover .rowSummary { + color: var(--dsw-alias-label-tertiary); + } +} + .endedRow.selectedRow .rowSummary { color: var(--dsw-alias-label-tertiary); } @@ -492,8 +508,10 @@ font-weight: 500; } -.editName:hover { - box-shadow: 0 1px var(--dsw-alias-border-l3); +@media (hover: hover) { + .editName:hover { + box-shadow: 0 1px var(--dsw-alias-border-l3); + } } .editName:focus { @@ -700,8 +718,10 @@ cursor: pointer; } -.retentionInfo:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .retentionInfo:hover { + color: var(--dsw-alias-label-primary); + } } .retentionInfo:focus-visible { @@ -844,8 +864,10 @@ transition: background 0.15s; } -.ruleValue:not(:disabled):hover .ruleValueFace { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .ruleValue:not(:disabled):hover .ruleValueFace { + background: var(--dsw-alias-interactive-bg-hover); + } } /* The trigger's own ring would box the row label as well; the value face @@ -937,8 +959,10 @@ transition: background 0.15s; } -.ruleInput:not(:disabled):hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .ruleInput:not(:disabled):hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* An ended task keeps its values readable: tertiary rather than dimmed ink. */ @@ -995,8 +1019,10 @@ appearance: none; } -.ruleIntervalInput:not(:disabled):hover { - background: transparent; +@media (hover: hover) { + .ruleIntervalInput:not(:disabled):hover { + background: transparent; + } } .ruleRows .ruleIntervalInput:focus-visible { @@ -1012,7 +1038,12 @@ opacity: 0; } -.ruleIntervalStepper:hover .ruleIntervalArrows, +@media (hover: hover) { + .ruleIntervalStepper:hover .ruleIntervalArrows { + opacity: 1; + } +} + .ruleIntervalStepper:focus-within .ruleIntervalArrows { opacity: 1; } @@ -1031,8 +1062,10 @@ cursor: pointer; } -.ruleIntervalArrow:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); +@media (hover: hover) { + .ruleIntervalArrow:hover:not(:disabled) { + background: var(--dsw-alias-bg-layer-1); + } } .ruleIntervalArrow:disabled { @@ -1115,8 +1148,10 @@ --dsh-scrollbar-track-margin: 12px; } -.instruction:hover { - border-color: var(--dsw-alias-border-l2); +@media (hover: hover) { + .instruction:hover { + border-color: var(--dsw-alias-border-l2); + } } .instruction:focus { @@ -1260,8 +1295,10 @@ cursor: pointer; } -.savedPromptToggle:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .savedPromptToggle:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .savedPromptToggle:focus-visible { @@ -1473,6 +1510,7 @@ /* The mock narrows the title strip's gap at this width and closes the actions' group gap between its icon buttons. */ + .detailTabsBar { gap: 16px; } diff --git a/packages/client/ui-schedule/src/client/TaskMenu.module.css b/packages/client/ui-schedule/src/client/TaskMenu.module.css index 5ddd158c37..d2fb446236 100644 --- a/packages/client/ui-schedule/src/client/TaskMenu.module.css +++ b/packages/client/ui-schedule/src/client/TaskMenu.module.css @@ -97,8 +97,10 @@ text-align: left; } -.item:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .item:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } /* Arrow navigation moves real focus, so the row the keyboard is on carries the @@ -158,8 +160,10 @@ color: var(--dsw-alias-state-error-primary); } -.danger:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); +@media (hover: hover) { + .danger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + } } .danger:focus-visible:not(:disabled) { diff --git a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx index a7892edc19..822b08571b 100644 --- a/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx +++ b/packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx @@ -3479,9 +3479,9 @@ describe('Task detail rule header and run-time card', () => { expect(link().className).toBe(within(detail).getByRole('button', { name: en['detail.more'] }).className) expect(within(link()).queryByText(en['detail.session'])).toBeNull() expect(within(link()).getByText(at.sessionId)).toBeDefined() - fireEvent.mouseEnter(detailContext(detail)!) + fireEvent.pointerEnter(detailContext(detail)!) expect(tooltip()).toBe(en['detail.session']) - fireEvent.mouseLeave(detailContext(detail)!) + fireEvent.pointerLeave(detailContext(detail)!) fireEvent.click(link()) expect(h.props.onOpenSession).toHaveBeenCalledExactlyOnceWith(at.sessionId) @@ -3489,9 +3489,9 @@ describe('Task detail rule header and run-time card', () => { state = { ...workspaces, archivedSessionIds: [at.sessionId] } h.update({}) expect(link().hasAttribute('disabled')).toBe(true) - fireEvent.mouseEnter(detailContext(detail)!) + fireEvent.pointerEnter(detailContext(detail)!) expect(tooltip()).toBe(en['detail.session']) - fireEvent.mouseLeave(detailContext(detail)!) + fireEvent.pointerLeave(detailContext(detail)!) state = workspaces h.update({}) const focused = link() @@ -3507,9 +3507,9 @@ describe('Task detail rule header and run-time card', () => { // The same button changes layout, so keyboard focus survives the switch. expect(link()).toBe(focused) expect(document.activeElement).toBe(focused) - fireEvent.mouseEnter(detailContext(detail)!) + fireEvent.pointerEnter(detailContext(detail)!) expect(tooltip()).toBeUndefined() - fireEvent.mouseLeave(detailContext(detail)!) + fireEvent.pointerLeave(detailContext(detail)!) strip = 240 act(() => { stripObserver().fire() }) expect(compact()).toBe(false) diff --git a/packages/client/ui-secret-mask/src/client/SecretText.module.css b/packages/client/ui-secret-mask/src/client/SecretText.module.css index 10d529c107..eef16c1586 100644 --- a/packages/client/ui-secret-mask/src/client/SecretText.module.css +++ b/packages/client/ui-secret-mask/src/client/SecretText.module.css @@ -46,8 +46,10 @@ cursor: pointer; } -.toggle:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .toggle:hover { + color: var(--dsw-alias-label-primary); + } } .toggle:focus-visible { diff --git a/packages/client/ui-settings-account/src/client/AccountMenu.module.css b/packages/client/ui-settings-account/src/client/AccountMenu.module.css index 7aa8a4d907..a873b4dd52 100644 --- a/packages/client/ui-settings-account/src/client/AccountMenu.module.css +++ b/packages/client/ui-settings-account/src/client/AccountMenu.module.css @@ -2,7 +2,9 @@ .anchor { width: 100%; } .trigger { user-select: none; display: flex; align-items: center; width: 100%; height: 44px; gap: 8px; padding: 6px; border: 0; border-radius: var(--dsw-radius-md); background: transparent; color: var(--dsw-alias-label-primary); font: inherit; font-size: 14px; cursor: pointer; } .trigger[data-collapsed='true'] { box-sizing: border-box; width: 36px; height: 36px; justify-content: center; gap: 0; padding: 0; } -.trigger:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .trigger:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .avatar { display: flex; align-items: center; justify-content: center; flex: none; width: 24px; height: 24px; border-radius: 50%; corner-shape: round; background: var(--dsw-alias-bg-skeleton); color: var(--dsw-alias-label-tertiary); } .label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } diff --git a/packages/client/ui-settings-account/src/client/AccountSection.module.css b/packages/client/ui-settings-account/src/client/AccountSection.module.css index f1c4ef4b78..2d6f0ba7fb 100644 --- a/packages/client/ui-settings-account/src/client/AccountSection.module.css +++ b/packages/client/ui-settings-account/src/client/AccountSection.module.css @@ -109,8 +109,10 @@ background: transparent; } -.linkButton:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .linkButton:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .linkButton:focus-visible { @@ -126,8 +128,10 @@ text-underline-offset: 3px; } -.unavailableLink:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .unavailableLink:hover { + color: var(--dsw-alias-label-primary); + } } .unavailableLink:focus-visible { @@ -143,8 +147,10 @@ border-color: transparent; } -.primary:hover { - background: var(--dsw-alias-button-primary-hover); +@media (hover: hover) { + .primary:hover { + background: var(--dsw-alias-button-primary-hover); + } } .signedOut { diff --git a/packages/client/ui-settings-account/src/client/DesktopOnboarding.module.css b/packages/client/ui-settings-account/src/client/DesktopOnboarding.module.css index 718b70a3d0..b47efa7e18 100644 --- a/packages/client/ui-settings-account/src/client/DesktopOnboarding.module.css +++ b/packages/client/ui-settings-account/src/client/DesktopOnboarding.module.css @@ -116,8 +116,10 @@ .creditActions { display: flex; gap: 24px; margin-top: 60px; } .creditActions > button { flex: 1; max-width: 160px; } .creditActions .laterAction { background: var(--dsw-alias-onboarding-secondary-fill); } -.creditActions .laterAction:hover:not(:disabled) { - background: linear-gradient(var(--dsw-alias-interactive-bg-hover), var(--dsw-alias-interactive-bg-hover)), var(--dsw-alias-onboarding-secondary-fill); +@media (hover: hover) { + .creditActions .laterAction:hover:not(:disabled) { + background: linear-gradient(var(--dsw-alias-interactive-bg-hover), var(--dsw-alias-interactive-bg-hover)), var(--dsw-alias-onboarding-secondary-fill); + } } .page button { transition: background-color 120ms ease, color 120ms ease, opacity 120ms ease; } @@ -180,10 +182,17 @@ mask-composite: exclude; opacity: 0; } -.card:hover::before { border-color: var(--dsw-alias-border-l4); } -.card:hover::after { opacity: 0.35; } +@media (hover: hover) { + .card:hover::before { border-color: var(--dsw-alias-border-l4); } + + .card:hover::after { opacity: 0.35; } +} .selected::before { opacity: 0; } -.selected::after, .selected:hover::after { opacity: 1; } +.selected::after { opacity: 1; } + +@media (hover: hover) { + .selected:hover::after { opacity: 1; } +} .selected::after { animation: borderSweep 14s linear infinite; } .officeCard, .card[data-process='compact'] { --card-border-stops: var(--dsw-gradient-onboarding-violet-stops); } .card[data-process='detailed'] { --card-border-stops: var(--dsw-gradient-onboarding-cyan-stops); } @@ -237,14 +246,18 @@ .navigation button { pointer-events: auto; } .navigation button { display: flex; align-items: center; gap: 20px; padding: 0; border: 0; background: transparent; color: var(--dsw-alias-label-tertiary); font: inherit; font-size: 16px; font-weight: 300; line-height: 32px; cursor: pointer; } .navigation button:focus-visible { outline: 2px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 5px; } -.navigation button:hover:not(:disabled) { color: var(--dsw-alias-label-primary); } +@media (hover: hover) { + .navigation button:hover:not(:disabled) { color: var(--dsw-alias-label-primary); } +} .backIcon { width: 17px; height: 14px; flex: none; background: currentColor; mask-size: contain; mask-repeat: no-repeat; mask-position: center; } .status { position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%); font-size: 14px; line-height: 22px; } .confirmation { padding: 22px 24px 0; color: var(--dsw-alias-label-primary); } .confirmationHeading { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 12px; } .confirmation h2 { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; } .confirmationClose { display: flex; align-items: center; justify-content: center; flex: none; width: 28px; height: 28px; border: 0; border-radius: 28px; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; } -.confirmationClose:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .confirmationClose:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .confirmation p { margin: 0; font-size: 14px; line-height: 22px; } .confirmationActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; } .dialogButton { border-radius: 12px; font-family: var(--dsw-font-family); font-weight: 500; } @@ -252,11 +265,17 @@ @media (max-height: 760px) { .purpose, .process { top: calc(50% - 180px); } + .purpose .copy h1, .process .copy h1 { font-size: 28px; line-height: 39px; } + .subtitle { margin-top: 16px; line-height: 21px; } + .card { padding: 24.5px 32.5px 32.5px; min-height: 187px; } + .processCards { min-height: 0; padding-block: 0; } + .primaryAction { margin-top: 40px; } + .creditActions { margin-top: 32px; } } @@ -270,9 +289,13 @@ @media (max-width: 1100px) { .page { --page-padding: 40px; } + .copy h1 { font-size: 26px; line-height: 40px; } + .heroDescription { font-size: 16px; line-height: 28px; } + .welcome .copy h1 { font-size: 28px; line-height: 48px; } + .fileIcons { gap: 3px; } } @@ -282,6 +305,7 @@ @media (prefers-reduced-motion: reduce) { .page button, .card, .card::before, .card::after { transition: none; } + .page, .fileIcons > svg, .selected::after { animation: none; } } diff --git a/packages/client/ui-settings-account/src/client/SignInDialog.module.css b/packages/client/ui-settings-account/src/client/SignInDialog.module.css index 91ae61410b..f9e5a031c5 100644 --- a/packages/client/ui-settings-account/src/client/SignInDialog.module.css +++ b/packages/client/ui-settings-account/src/client/SignInDialog.module.css @@ -3,7 +3,9 @@ .header { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 22px 24px 12px; } .title { margin: 0; font-size: 16px; line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); } .close { box-sizing: border-box; display: inline-flex; flex: none; align-items: center; justify-content: center; width: 28px; height: 28px; padding: 6px; border: 0; border-radius: var(--dsw-radius-sm); background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; } -.close:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .close:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .actions { box-sizing: border-box; display: flex; justify-content: flex-end; align-items: center; width: 100%; padding: 0 24px; gap: 8px; } .primaryButton, .secondaryButton { box-sizing: border-box; min-width: 72px; height: 36px; border-radius: var(--dsw-radius-md); font-size: 14px; line-height: 22px; } .primaryButton { padding: 0 16px; font-weight: 500; } @@ -13,4 +15,6 @@ .spinner { flex: none; animation: spin 1s linear infinite; } .primaryButton:has(.spinner) { opacity: 1; } @keyframes spin { to { transform: rotate(360deg); } } -@media (prefers-reduced-motion: reduce) { .spinner { animation: none; } } +@media (prefers-reduced-motion: reduce) { + .spinner { animation: none; } +} diff --git a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css index 0f695de58a..ef1cc181ae 100644 --- a/packages/client/ui-settings-general/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings-general/src/client/SettingsRoot.module.css @@ -35,8 +35,10 @@ line-height: 22px; } -.trigger:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .trigger:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* Rail trigger: the same 36x36 box and 12px rounding as the other rail @@ -156,8 +158,10 @@ text-align: left; } -.navCell:hover { - background: var(--dsw-specific-sidebar-nav-item-hover); +@media (hover: hover) { + .navCell:hover { + background: var(--dsw-specific-sidebar-nav-item-hover); + } } .navCell.active { @@ -220,8 +224,10 @@ color: var(--dsw-alias-label-primary); } -.close:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .close:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */ diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index ea67cbb641..1859676066 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -156,8 +156,10 @@ color: var(--dsw-alias-label-primary-foreground); } -.primaryButton:hover:not(:disabled) { - background: var(--dsw-alias-button-primary-hover); +@media (hover: hover) { + .primaryButton:hover:not(:disabled) { + background: var(--dsw-alias-button-primary-hover); + } } .secondaryButton, @@ -167,13 +169,15 @@ color: var(--dsw-alias-label-primary); } -.secondaryButton:hover:not(:disabled), -.addButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); -} +@media (hover: hover) { + .secondaryButton:hover:not(:disabled), + .addButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } -.secondaryButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-solid); + .secondaryButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-solid); + } } .dangerButton { @@ -193,8 +197,10 @@ cursor: pointer; } -.dangerButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); +@media (hover: hover) { + .dangerButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + } } /* Provider-row controls use the compact Button size. */ @@ -291,12 +297,18 @@ cursor: help; } -.helpButton:hover, .helpButton:focus-visible { color: var(--dsw-alias-label-primary); border-color: var(--dsw-alias-label-tertiary); } +@media (hover: hover) { + .helpButton:hover { + color: var(--dsw-alias-label-primary); + border-color: var(--dsw-alias-label-tertiary); + } +} + .linkButton { box-sizing: border-box; display: inline-flex; @@ -313,9 +325,11 @@ cursor: pointer; } -.linkButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .linkButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + } } .advancedHint { @@ -436,8 +450,10 @@ transform: rotate(45deg) translate(-1px, -1px); } -.customizedSummary:hover { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .customizedSummary:hover { + color: var(--dsw-alias-label-primary); + } } .customizedBody { @@ -518,9 +534,11 @@ cursor: pointer; } -.iconButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .iconButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + } } .iconButton:disabled { @@ -529,9 +547,11 @@ } /* The delete glyph keeps the danger tint the rest of the section uses. */ -.iconButtonDanger:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); - color: var(--dsw-alias-state-error-primary); +@media (hover: hover) { + .iconButtonDanger:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + color: var(--dsw-alias-state-error-primary); + } } .modelAdvanced { @@ -638,8 +658,10 @@ cursor: pointer; } -.addModelButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .addModelButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } .input { @@ -700,8 +722,10 @@ select.input { cursor: pointer; } -.keyToggle:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .keyToggle:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + } } .keyToggle:focus-visible { @@ -744,8 +768,10 @@ select.input { color: var(--dsw-alias-state-error-primary); } -.deleteConfirm:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover-danger); +@media (hover: hover) { + .deleteConfirm:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover-danger); + } } /* Icon-button label seat: named for assistive tech and for the tests that diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 510d23a208..da48676b6f 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -286,7 +286,7 @@ function renderPiAiEditor( function authHelpText(): string | null { const button = screen.queryByRole('button', { name: en.authHelp }) if (button === null) return null - fireEvent.mouseEnter(button) + fireEvent.pointerEnter(button) return screen.getByRole('tooltip').textContent } diff --git a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml index 6aca3b6682..17f73de103 100644 --- a/packages/client/ui-settings-plugin-inventory/README.i18n.yaml +++ b/packages/client/ui-settings-plugin-inventory/README.i18n.yaml @@ -18,8 +18,8 @@ en: 67637808e7940157 zh: d4d0716ea4d33e4a /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/reading-a-card: - en: bb000df9983be741 - zh: d54ee50bafd136fb + en: 3cb7d3a63d5c0fea + zh: d127dfbb58e50e4b /deepseek-ai-dsh-client-ui-settings-plugin-inventory/use-this-package/the-preset-switcher: en: 84e5938329199a22 zh: 6f5cfaac56433e8b diff --git a/packages/client/ui-settings-plugin-inventory/README.md b/packages/client/ui-settings-plugin-inventory/README.md index bf5ad3d53e..ceac087bd9 100644 --- a/packages/client/ui-settings-plugin-inventory/README.md +++ b/packages/client/ui-settings-plugin-inventory/README.md @@ -35,6 +35,8 @@ Each collapsed card uses the plugin's localized title and description when avail A small enablement tag marks only the states that differ from plainly enabled: disabled, conditional, provided by presets, and failed; a plainly enabled row carries no tag. A colored root-fiber status dot marks the live phases no tag states: `pending` maps to idle, while `loading` and `unloading` map to ongoing; an `active` or `failed` fiber shows no dot. The stable entry id appears in a code chip at the card's bottom-left, in line with the title, only when it differs from the title: a composition-generated id drops its leading `include:` marker there, and an id that then repeats the title is omitted. Whenever the chip shows, its hover text and the card's accessible name carry the complete id; search and expanded details always retain it. Long entry ids truncate in the chip and remain available on hover. Expanding one card reveals its full description, the declared entry id, the full module specifier, and the state facts: a preset row names the preset it comes from, its runtime status when the composition is live, and its disable condition when it carries one; a preset-provided global row explains that agent presets provide it per session, names the presets that enable it. Preset names resolve through the shared `presetDisplayText` fold (`dsh-agent-preset-registry/display`) over [`ui-agent-preset`](../ui-agent-preset/README.md)'s dictionaries: shipped presets follow the active locale while user-authored ones keep their own metadata, so an English surface never echoes the preset declarations' Chinese names. Search filters both groups by localized title and description, module name, and entry id. +A card paints its hover surface only where the pointer can hover: a tap answers with the pressed fill, and an expanded card keeps its open surface on every device. The search field takes a 16px face under a coarse pointer, so iOS Safari does not zoom the page for a focused field. + ### The preset switcher The switcher is the same selector-pill-plus-menu control the General settings rows use. It lists every roster preset — the default suffixed as such, broken ones marked — and changes only what the list shows: it writes no settings, and selecting a broken preset shows the discovery-reported reason in place of rows. Choosing the default preset or a session's preset stays where it was: the Agent presets section and the new-session screen. diff --git a/packages/client/ui-settings-plugin-inventory/README.zh.md b/packages/client/ui-settings-plugin-inventory/README.zh.md index 6a992313c1..f061018cef 100644 --- a/packages/client/ui-settings-plugin-inventory/README.zh.md +++ b/packages/client/ui-settings-plugin-inventory/README.zh.md @@ -35,6 +35,8 @@ kind: "package-reference" 表示启停状态的小标签只标记与普通启用不同的状态:已停用、条件启用、由预设提供与启动失败;普通启用的行不带标签。彩色根 fiber 状态圆点标记没有标签表达的存活阶段:`pending` 映射为 idle,`loading` 与 `unloading` 映射为 ongoing;`active` 或 `failed` fiber 不显示圆点。稳定的条目 id 仅在与标题不同时显示,位于卡片左下角的代码标签中,与标题左对齐:组合生成的 id 在此省略开头的 `include:` 标记,省略后与标题相同的 id 不再重复显示。显示代码标签时,其悬停提示与卡片的无障碍名称带完整 id;搜索与展开详情始终保留完整 id。长条目 id 会在代码标签内截断,悬停时仍可查看完整值。展开卡片后会显示完整描述、声明的条目 id、完整模块标识与状态事实:预设行说明它来自哪个预设、组合存活时的运行状态,以及它携带的禁用条件;被预设提供的全局行说明它由 Agent 预设按会话提供、列出启用它的预设。预设名经共享的 `presetDisplayText` 纯函数(`dsh-agent-preset-registry/display`)叠在 [`ui-agent-preset`](../ui-agent-preset/README.zh.md) 的字典上解析:内置预设走当前语言,用户自建预设保留自己的元数据,因此英文界面不会回显预设声明里的中文名。搜索按本地化标题与描述、模块名称及条目 id 过滤两组。 +卡片只在可悬停的指针下绘制悬停底色:点按以按下填充回馈,展开的卡片在任何设备上保持展开底色。搜索框在粗指针下使用 16px 字号,iOS Safari 不会因聚焦而放大页面。 + ### 预设切换器 切换器与通用设置各行使用同一种「选择胶囊 + 菜单」控件。它列出 roster 的每个预设——默认项带后缀、坏预设带标记——并且只改变列表显示什么:它不写任何设置,选中坏预设时在行的位置展示 discovery 报告的原因。选择默认预设或会话预设的入口仍在原处:Agent 预设分区与新会话页。 diff --git a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css index 5836c2b56a..db943b1066 100644 --- a/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css +++ b/packages/client/ui-settings-plugin-inventory/src/client/PluginInventorySettingsTab.module.css @@ -139,11 +139,22 @@ cursor: pointer; } -.cardContent:hover, .card[data-open='true'] > .cardContent { background: var(--dsw-alias-interactive-bg-hover); } +/* Hover stays behind @media (hover: hover) so a tap cannot latch it; the press + state carries the feedback a touch client would otherwise lose. */ +.cardContent:active { + background: var(--dsw-alias-interactive-bg-active); +} + +@media (hover: hover) { + .cardContent:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + .cardContent:focus-visible { outline: var(--dsw-focus-ring-width) solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -2px; @@ -319,8 +330,14 @@ cursor: pointer; } -.switcher:hover { - background: var(--dsw-alias-interactive-bg-hover); +.switcher:active { + background: var(--dsw-alias-interactive-bg-active); +} + +@media (hover: hover) { + .switcher:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .switcher:focus-visible { @@ -486,3 +503,10 @@ grid-template-columns: minmax(0, 1fr); } } + +/* iOS Safari zooms the visual viewport for a focused field under 16px. */ +@media (pointer: coarse) { + .search input { + font-size: 16px; + } +} diff --git a/packages/client/ui-settings-plugin-inventory/tests/touch-styles.client.spec.ts b/packages/client/ui-settings-plugin-inventory/tests/touch-styles.client.spec.ts new file mode 100644 index 0000000000..579edfcdca --- /dev/null +++ b/packages/client/ui-settings-plugin-inventory/tests/touch-styles.client.spec.ts @@ -0,0 +1,86 @@ +/** + * Built-in plugin inventory touch rules, asserted against the CSS text on disk. + * + * jsdom resolves neither `pointer` nor `hover` media features, so these gates read + * the sheet instead: a `:hover` rule outside `@media (hover: hover)` latches onto a + * tap and strands the card in its pointer-only state, and the 13px search field + * makes iOS Safari zoom the visual viewport when it takes focus. + */ +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const css = readFileSync(fileURLToPath(new URL('../src/client/PluginInventorySettingsTab.module.css', import.meta.url)), 'utf8') +/** Comment bodies blanked, so prose about `:hover` is not read as a rule. */ +const rules = css.replace(/\/\*[\s\S]*?\*\//g, text => ' '.repeat(text.length)) + +/** Every `:hover` selector sitting in the media query that encloses it. */ +function hovers(source: string): { line: string; hoverMedia: boolean }[] { + const stack: (string | null)[] = [] + const found: { line: string; hoverMedia: boolean }[] = [] + for (const token of source.matchAll(/@media[^{]*\{|@container[^{]*\{|:hover|\{|\}/g)) { + const text = token[0] + if (text === '}') { + stack.pop() + continue + } + if (text === '{') { + stack.push(null) + continue + } + if (text.startsWith('@')) { + // The matched text includes the block's opening brace, which the frame stands for. + stack.push(text.slice(0, -1)) + continue + } + const start = source.lastIndexOf('\n', token.index ?? 0) + 1 + const end = source.indexOf('\n', token.index ?? 0) + found.push({ + line: source.slice(start, end === -1 ? undefined : end).trim(), + hoverMedia: stack.some(frame => frame !== null && /hover:\s*hover/.test(frame)), + }) + } + return found +} + +/** The declarations inside every `@media (hover: hover)` block, joined. */ +function hoverBlock(): string { + return [...rules.matchAll(/@media \(hover: hover\) \{([\s\S]*?)\n\}/g)].map(match => match[1] ?? '').join('\n') +} + +/** The declarations of one rule, by its literal selector. */ +function ruleBody(selector: string): string { + const start = rules.indexOf(`${selector} {`) + if (start === -1) throw new Error(`PluginInventorySettingsTab.module.css has no \`${selector}\` rule`) + const open = rules.indexOf('{', start) + return rules.slice(open + 1, rules.indexOf('}', open)) +} + +describe('built-in plugin inventory touch rules', () => { + it('fences every hover affordance behind a hover-capable pointer', () => { + // The card background and the preset switcher are the tab's only hover states; + // unguarded, a single tap leaves the card painted as hovered. + const unguarded = hovers(rules).filter(state => !state.hoverMedia) + expect(unguarded.map(state => state.line)).toEqual([]) + expect(rules).toContain('.cardContent:hover') + expect(rules).toContain('.switcher:hover') + }) + + it('keeps the open card painted without a pointer', () => { + // An expanded card shows the same surface as a hovered one, and it must hold on + // a touch client: it belongs outside the hover fence, not inside it. + expect(ruleBody(".card[data-open='true'] > .cardContent")) + .toContain('background: var(--dsw-alias-interactive-bg-hover)') + expect(hoverBlock()).not.toContain('data-open') + }) + + it('gives touch clients a press state where the hover state is fenced off', () => { + expect(ruleBody('.cardContent:active')).toContain('background: var(--dsw-alias-interactive-bg-active)') + expect(ruleBody('.switcher:active')).toContain('background: var(--dsw-alias-interactive-bg-active)') + }) + + it('keeps the focused search field at 16px under a coarse pointer', () => { + const coarse = /@media \(pointer: coarse\) \{([\s\S]*?)\n\}/.exec(rules)?.[1] ?? '' + expect(coarse).toMatch(/\.search input \{[^}]*font-size: 16px/) + }) +}) diff --git a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css index 17c270692e..5a524b709b 100644 --- a/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css +++ b/packages/client/ui-settings-plugins/src/client/PluginsSettingsSection.module.css @@ -40,11 +40,16 @@ cursor: pointer; } -.tab:hover, .tab[data-active='true'] { color: var(--dsw-alias-label-primary); } +@media (hover: hover) { + .tab:hover { + color: var(--dsw-alias-label-primary); + } +} + .tab[data-active='true']::after, .tab:focus-visible::after { position: absolute; diff --git a/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css index 51c0d1749f..b0e9332d28 100644 --- a/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css +++ b/packages/client/ui-settings-subagent/src/client/SubagentModelSelectionFields.module.css @@ -107,8 +107,10 @@ cursor: pointer; } -.model:hover { - background: var(--dsw-alias-bg-layer-4); +@media (hover: hover) { + .model:hover { + background: var(--dsw-alias-bg-layer-4); + } } .modelName, diff --git a/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css b/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css index a6a5e67d50..a3f2d17cdd 100644 --- a/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css +++ b/packages/client/ui-settings-web-search/src/client/WebSearchCard.module.css @@ -25,6 +25,8 @@ text-decoration: none; } -.link:hover { - text-decoration: underline; +@media (hover: hover) { + .link:hover { + text-decoration: underline; + } } diff --git a/packages/client/ui-shortcuts/src/client/Reference.module.css b/packages/client/ui-shortcuts/src/client/Reference.module.css index 86c2782ab2..09d2e2a0ed 100644 --- a/packages/client/ui-shortcuts/src/client/Reference.module.css +++ b/packages/client/ui-shortcuts/src/client/Reference.module.css @@ -48,7 +48,9 @@ cursor: pointer; white-space: nowrap; } -.button:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .button:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .dialog { box-sizing: border-box; width: min(480px, 100%); @@ -91,7 +93,9 @@ color: var(--dsw-alias-label-primary); cursor: pointer; } -.close:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .close:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .searchRow { box-sizing: border-box; flex: 0 0 60px; @@ -158,7 +162,9 @@ font: inherit; cursor: pointer; } -.resetAll:enabled:hover { color: var(--dsw-alias-label-secondary); } +@media (hover: hover) { + .resetAll:enabled:hover { color: var(--dsw-alias-label-secondary); } +} .resetAll:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; } .resetAll:disabled { cursor: default; opacity: 0.45; } .modifiedCount { color: var(--dsw-alias-label-tertiary); } @@ -188,10 +194,16 @@ p.hint { margin: 6px 0; } /* Keyboard focus reveals actions without retaining a mouse interaction's hover state. */ .rowActions { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 26px; color: var(--dsw-alias-label-tertiary); opacity: 0; } -.row:hover .rowActions, .row:has(:focus-visible) .rowActions { opacity: 1; } +.row:has(:focus-visible) .rowActions { opacity: 1; } + +@media (hover: hover) { + .row:hover .rowActions { opacity: 1; } +} .inlineAction:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; border-radius: 4px; } .rowButton { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: inherit; background: transparent; cursor: pointer; } -.row:has(.rowButton:enabled):hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .row:has(.rowButton:enabled):hover { background: var(--dsw-alias-interactive-bg-hover); } +} .rowButton:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: -1px; } .binding .keyBadge { color: var(--dsw-alias-label-secondary); } .binding .fixedKeyBadge { height: 26px; color: var(--dsw-alias-label-tertiary); } @@ -209,10 +221,18 @@ p.hint { margin: 6px 0; } .toastSuccess { color: var(--dsw-alias-state-success-secondary); } .toastError { color: var(--dsw-alias-state-warn-secondary); } .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } -@media (hover: none) { .rowActions { opacity: 1; } } +@media (hover: none) { + .rowActions { opacity: 1; } +} .button:disabled, .rowButton:disabled, .inlineAction:disabled, .recorder[aria-disabled="true"] { cursor: default; opacity: 0.45; } .search::-webkit-search-cancel-button { -webkit-appearance: none; } .clearSearch { box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; flex: none; width: 16px; height: 16px; padding: 0; border: 0.5px solid var(--dsw-alias-label-tertiary); border-radius: 50%; corner-shape: round; background: transparent; color: var(--dsw-alias-label-secondary); cursor: pointer; } -.clearSearch:hover { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .clearSearch:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .clearSearch:focus-visible { outline: 1px solid var(--dsw-focus-ring-color, var(--dsw-alias-state-business-primary)); outline-offset: 2px; } + +@media (hover: none) { + .rowActions { opacity: 1; } +} diff --git a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx index 2e79e2fb88..0a1f8e988f 100644 --- a/packages/client/ui-shortcuts/tests/reference.client.spec.tsx +++ b/packages/client/ui-shortcuts/tests/reference.client.spec.tsx @@ -103,12 +103,12 @@ it.each(['macos', 'windows'] as const)('shows the reference, filters labels and fireEvent.keyDown(search, { key: 'Escape' }) expect(store.getSnapshot().query).toBe('') expect(document.activeElement).toBe(opener) - fireEvent.mouseEnter(opener) + fireEvent.pointerEnter(opener) expect(screen.getByRole('tooltip').getAttribute('aria-label')).toBe(`${en['global-hint']} ${platform === 'macos' ? '⌘ /' : 'Ctrl + /'}`) act(() => { catalog.set(catalog.getSnapshot().map(row => row.id === 'shortcuts.open' ? { ...row, binding: null, keys: [], aria: undefined } : row)) }) expect(screen.queryByRole('tooltip')).toBeNull() - fireEvent.mouseLeave(opener); fireEvent.mouseEnter(opener) + fireEvent.pointerLeave(opener); fireEvent.pointerEnter(opener) expect(screen.queryByRole('tooltip')).toBeNull() act(() => { catalog.set([]) }) expect(opener.hasAttribute('aria-keyshortcuts')).toBe(false) @@ -293,9 +293,9 @@ it('keeps command labels free of tooltips while retaining binding edits', () => const label = screen.getByText('Open settings', { selector: 'span' }) expect(label.textContent).toBe('Open settings') expect(screen.queryByText('Unavailable')).toBeNull() - fireEvent.mouseEnter(label) + fireEvent.pointerEnter(label) expect(screen.queryByRole('tooltip')).toBeNull() - fireEvent.mouseLeave(label) + fireEvent.pointerLeave(label) fireEvent.focus(label) expect(screen.queryByRole('tooltip')).toBeNull() expect(label.hasAttribute('tabindex')).toBe(false) diff --git a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css index 94fc92ac45..84970df416 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css +++ b/packages/client/ui-sidebar-browser/src/client/view/Browser.module.css @@ -34,9 +34,11 @@ cursor: pointer; } -.tool:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .tool:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); + } } .tool:disabled { diff --git a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml index 97adbc6c21..836196338d 100644 --- a/packages/client/ui-sidebar-documentpreview/README.i18n.yaml +++ b/packages/client/ui-sidebar-documentpreview/README.i18n.yaml @@ -24,8 +24,8 @@ en: b056bbe0f74fe328 zh: fd9ac88f08825afc /deepseek-ai-dsh-client-ui-sidebar-documentpreview/excel-preview: - en: b99b0e0b8efc2853 - zh: 4327352d1334fc6b + en: 595499992ceea693 + zh: 83c6147c46a80dca /deepseek-ai-dsh-client-ui-sidebar-documentpreview/office-preview: en: 372d8ce6492e6896 zh: 912f613bc9221aa3 diff --git a/packages/client/ui-sidebar-documentpreview/README.md b/packages/client/ui-sidebar-documentpreview/README.md index 43ac41c129..a75a39a89a 100644 --- a/packages/client/ui-sidebar-documentpreview/README.md +++ b/packages/client/ui-sidebar-documentpreview/README.md @@ -70,7 +70,7 @@ A leading YAML frontmatter block, opened by `---` on the first line and closed b ## Excel preview -Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs start at the left edge; overflowing tabs scroll with horizontal trackpad gestures or left/right navigation beside a separate zoom control. Spreadsheet controls retain dark text on their light background in both app themes. Pixel-based trackpad gestures pan the grid on both axes together, including diagonally, at the gesture’s speed; frozen headings stay fixed. Reversing direction at a sheet edge retains the next gesture’s movement. Selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service. +Open `.xlsx`, `.xls`, `.csv`, and `.tsv` directly in the browser with worksheet tabs, cell selection, copying, and a read-only formula bar. XLSX retains fonts, solid fills, borders, number formats, rich text, merged cells, row and column sizes, hidden rows/columns/sheets, and frozen headings. XLS retains saved values, formulas, number formats, merges, and available row/column metadata; fonts, borders, and frozen panes are unsupported. Workbooks display saved formula results without recalculating; missing results remain blank, and a compact formula-bar warning marks workbooks whose displayed results may be incomplete or inaccurate. The warning supports hover and click or tap to read its explanation. The workbook viewport fills the preview pane and follows its size changes without reloading its sheets or selection. Worksheet tabs start at the left edge; overflowing tabs scroll with horizontal trackpad gestures or left/right navigation beside a separate zoom control. Spreadsheet controls retain dark text on their light background in both app themes. Pixel-based trackpad gestures pan the grid on both axes together, including diagonally, at the gesture’s speed; frozen headings stay fixed. Reversing direction at a sheet edge retains the next gesture’s movement. Selection statistics and inactive worksheet menu arrows are hidden. Frozen headings remain fixed while scrolling, without freeze dividers or drag handles. Spreadsheet preview does not call the Office conversion service. XLSX preview omits DrawingML parts from an in-memory copy before parsing and ignores worksheet drawing references. The original file and worksheet XML remain unchanged. A notice above the table lists detected charts, images, shapes, and conditional formatting that are not displayed, and recommends opening the workbook in a system application. Files without these detected features have no notice; formula warnings remain separate. diff --git a/packages/client/ui-sidebar-documentpreview/README.zh.md b/packages/client/ui-sidebar-documentpreview/README.zh.md index daaf6288ab..babe6e7385 100644 --- a/packages/client/ui-sidebar-documentpreview/README.zh.md +++ b/packages/client/ui-sidebar-documentpreview/README.zh.md @@ -70,7 +70,7 @@ PNG、JPEG、GIF、WebP、BMP、ICO 和 SVG 通过 Blob URL 在 `` 静态 ## Excel 预览 -直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。工作表标签从左侧边缘开始排列;标签溢出时可用触控板横向手势或左右按钮滚动,缩放控件独立占位。表格控件在应用的明暗两种主题下均保持浅色背景和深色文字。触控板的像素位移可同时平移表格的横纵两个方向,包括斜向平移,移动距离跟随手势;冻结标题保持固定。到达工作表边缘后反向移动,仍会保留下一次手势的位移。隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。 +直接在浏览器中打开 `.xlsx`、`.xls`、`.csv` 和 `.tsv`,支持工作表标签、单元格选择、复制和只读公式栏。XLSX 保留字体、纯色填充、边框、数字格式、富文本、合并单元格、行列尺寸、隐藏行列及工作表,以及冻结标题。XLS 保留已保存的值、公式、数字格式、合并及可用的行列元数据;不支持字体、边框和冻结窗格。工作簿显示已保存的公式结果而不重新计算;缺少的结果保持为空,公式栏旁的紧凑提示会标记显示结果可能不完整或不准确的工作簿。该提示支持悬停查看,也可通过鼠标或触摸点击查看说明。工作簿视图填满预览面板,并随面板尺寸变化自动调整,无需重新加载工作表或选择状态。工作表标签从左侧边缘开始排列;标签溢出时可用触控板横向手势或左右按钮滚动,缩放控件独立占位。表格控件在应用的明暗两种主题下均保持浅色背景和深色文字。触控板的像素位移可同时平移表格的横纵两个方向,包括斜向平移,移动距离跟随手势;冻结标题保持固定。到达工作表边缘后反向移动,仍会保留下一次手势的位移。隐藏选区统计栏及无效的工作表菜单箭头。冻结标题在滚动时保持固定,不显示冻结分隔线和拖拽条。表格预览不调用 Office 转换服务。 XLSX 预览在解析前从内存副本中移除 DrawingML 部件,并忽略工作表的绘图引用。原文件和工作表 XML 保持不变。表格上方的提示列出实际检测到但未展示的图表、图片、形状和条件格式,并建议使用系统应用打开工作簿。未检测到这些内容的文件不显示此提示;公式提示保持独立。 diff --git a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css index dc6a77e68f..72b808750c 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/TextPreview.module.css @@ -188,8 +188,10 @@ cursor: pointer; } -.retry:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .retry:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } /* The type's 16px sheet before the chip's text; the chip's title row centres @@ -235,8 +237,10 @@ cursor: pointer; } -.action:hover { - background: var(--dsw-alias-bg-layer-3); +@media (hover: hover) { + .action:hover { + background: var(--dsw-alias-bg-layer-3); + } } /* The next page, asked for where the loaded text ends. */ @@ -254,9 +258,11 @@ cursor: pointer; } -.more:hover:not(:disabled) { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-bg-layer-3); +@media (hover: hover) { + .more:hover:not(:disabled) { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-bg-layer-3); + } } .more:disabled { @@ -288,9 +294,11 @@ height: 15px; } -.tool:hover { - color: var(--dsw-alias-label-primary); - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .tool:hover { + color: var(--dsw-alias-label-primary); + background: var(--dsw-alias-interactive-bg-hover); + } } .tool[data-textpreview-tool='auto-refresh'][aria-pressed='true'] { diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css index 69f5a58c44..f7a53a6a06 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/ExcelBody.module.css @@ -102,11 +102,18 @@ border-radius: var(--dsw-radius-sm); } -.formulaWarning:hover, .formulaWarning:focus-visible { +.formulaWarning:focus-visible { background: var(--dsw-alias-state-warn-tertiary); outline: none; } +@media (hover: hover) { + .formulaWarning:hover { + background: var(--dsw-alias-state-warn-tertiary); + outline: none; + } +} + .withFormulaWarning :global(.fortune-fx-input) { padding-left: 24px; } diff --git a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx index ca3fcb8425..7b551a4434 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/excel/excel.tsx @@ -55,7 +55,7 @@ export function ExcelBody({ content, format, limits, t, loading }: LoadedExcelBo {t('unsupportedNotice', { features: state.value.unsupportedFeatures.map(feature => t(feature)).join(t('featureSeparator')) })} }
- {hasFormulas && + {hasFormulas && diff --git a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css index f4d3368854..4eb626f624 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css +++ b/packages/client/ui-sidebar-documentpreview/src/client/office/FontNotice.module.css @@ -3,14 +3,26 @@ flex: none; } -.anchor .warning, .anchor .warning:hover { +.anchor .warning { color: var(--dsw-alias-state-warn-label); } -.anchor .warning:hover, .anchor .warning[aria-expanded='true'] { +@media (hover: hover) { + .anchor .warning:hover { + color: var(--dsw-alias-state-warn-label); + } +} + +.anchor .warning[aria-expanded='true'] { background: var(--dsw-alias-state-warn-tertiary); } +@media (hover: hover) { + .anchor .warning:hover { + background: var(--dsw-alias-state-warn-tertiary); + } +} + .panel { --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); diff --git a/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx b/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx index e2b70a9e5d..16c2912d28 100644 --- a/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx +++ b/packages/client/ui-sidebar-documentpreview/src/client/zoom/ZoomControls.tsx @@ -45,7 +45,8 @@ export const ZoomControls = forwardRef { setHovered(true) }} onPointerLeave={() => { setHovered(false) }} + onPointerEnter={(event) => { if (event.pointerType !== 'touch') setHovered(true) }} + onPointerLeave={() => { setHovered(false) }} onFocusCapture={() => { setFocused(true) }} onBlurCapture={() => { setFocused(false) }}> }
{!configured && (status === 'unavailable' ?

{t(`${kind}Unavailable`)}

@@ -132,24 +135,25 @@ export function PermissionPanel(props: PermissionPanelProps) { const state = props.useCuaPermissions(value => value) const observed = state.system.value const apps = state.applications.value - const busy = state.system.busy - const appsBusy = state.applications.busy - const loading = observed === null && (busy || state.system.error === null) - const application = !busy && state.system.error !== null ? undefined : observed?.launchApplication + const transitioning = state.changing + const busy = transitioning || state.system.busy + const appsBusy = transitioning || state.applications.busy + const loading = observed === null && state.system.error === null + const application = state.system.error !== null ? undefined : observed?.launchApplication useEffect(() => { refresh() window.addEventListener('focus', refresh) return () => { window.removeEventListener('focus', refresh) } }, [refresh]) - return
+ return

{t('title')}

- {!busy && state.system.error !== null &&
+ {state.system.error !== null &&
{t('queryFailed', { message: state.system.error })} - +
} {(['accessibility', 'screenRecording'] as const).map(permission =>
@@ -158,22 +162,20 @@ export function PermissionPanel(props: PermissionPanelProps) {
)}
- {observed !== null && (!observed.accessibility || !observed.screenRecording) &&

- {t(application === undefined ? 'permissionHintUnknown' : 'permissionHint')} -

} +

{t('permissionHint')}

{t('applicationsTitle')}

- {(['applications', 'icons'] as const).map(key => !state[key].busy && state[key].error !== null &&
+ {(['applications', 'icons'] as const).map(key => state[key].error !== null &&
{t(key === 'applications' ? 'applicationsFailed' : 'iconsFailed', { message: state[key].error })} - +
)} - {apps === null ?
: <> + {apps === null ?

{t('noApps')}

: <> {apps.allowedApps.length === 0 && apps.blockedApps.length === 0 &&

{t('noApps')}

} {apps.allowedApps.length > 0 && } diff --git a/packages/experimental/cua/src/client/controller.ts b/packages/experimental/cua/src/client/controller.ts index 0ecdbdf4d0..54d679de1e 100644 --- a/packages/experimental/cua/src/client/controller.ts +++ b/packages/experimental/cua/src/client/controller.ts @@ -21,6 +21,7 @@ interface Query { /** Independently settled observations; the overall busy flag follows the latest refresh. */ export type PermissionView = { [K in keyof PermissionData]: Query } & { busy: boolean + changing: boolean toast: { id: number; kind: 'requested' | 'opened' | 'revoked' | 'retryAllowed' } | { id: number; kind: 'failed'; message: string } | null } /** Slot actions and the renderer-bound permission projection. */ @@ -34,7 +35,7 @@ export interface PermissionFace { dismissToast: () => void } -/** Serializes user actions and drops superseded refresh results or results arriving after disposal. */ +/** Serializes actions, pauses reads during management operations, and drops superseded or disposed results. */ export class PermissionController { /** Last observed permissions and the lifetime of the current refresh/action. */ readonly state = createSnapshotStore({ @@ -42,11 +43,12 @@ export class PermissionController { system: { value: null, busy: false, error: null }, applications: { value: null, busy: false, error: null }, icons: { value: null, busy: false, error: null }, - busy: false, toast: null, + busy: false, changing: false, toast: null, }) private tail: Promise = Promise.resolve() private reads: Promise = Promise.resolve() private closed = false + private epoch = 0 private sequence = 0 /** @param remote Generated CUA Remote namespace; failed results are handled with query and action failures. */ constructor(private readonly remote: Context['remote']['cuaAccess']) {} @@ -65,11 +67,26 @@ export class PermissionController { dismissToast: () => { if (!this.closed) this.state.update((state) => { state.toast = null }) }, } } + /** + * Update query admission from the management operation's synchronous notification. + * @param changing Whether a bundle or row operation is pending; settlement refreshes observations. + */ + setChanging(changing: boolean): void { + if (this.closed || changing === this.state.getSnapshot().changing) return + this.epoch++ + this.state.update((state) => { + state.changing = changing + state.busy = true + for (const key of ['runtime', 'system', 'applications', 'icons'] as const) state[key].busy = true + }) + if (!changing) this.enqueue() + } /** Prevent publication after UI withdrawal and join the accepted Remote work. */ async dispose(): Promise { this.closed = true; await this.tail; await this.reads } private enqueue(action?: () => Promise>, kind?: 'requested' | 'opened' | 'revoked' | 'retryAllowed'): void { - if (this.closed) return - const current = () => !this.closed + if (this.closed || this.state.getSnapshot().changing) return + const epoch = this.epoch + const current = () => !this.closed && epoch === this.epoch const sequence = ++this.sequence this.state.update((state) => { state.busy = true diff --git a/packages/experimental/cua/src/client/locales.ts b/packages/experimental/cua/src/client/locales.ts index 0702e20597..7b4062f707 100644 --- a/packages/experimental/cua/src/client/locales.ts +++ b/packages/experimental/cua/src/client/locales.ts @@ -26,8 +26,7 @@ export const en = { accessibilityUse: 'Read interface controls, click, and type.', screenRecordingUse: 'Capture the selected application window.', accessibilityPermission: 'In System Settings, allow {application} to read interface controls, click, and type.', screenRecordingPermission: 'In System Settings, allow {application} to capture application windows.', - permissionHint: 'Enable the application in System Settings. Follow the macOS prompt if it names another application.', - permissionHintUnknown: 'Enable these permissions for the application named in the macOS prompt.', + permissionHint: 'Manage permissions in System Settings for the application named by macOS.', granted: 'Allowed', unknown: 'Unknown', request: 'Request permission', settings: 'Open System Settings', retry: 'Retry', allowedApps: 'Always-allowed applications', blockedApps: 'Applications not allowed', retryApproval: 'Allow asking again', @@ -63,8 +62,7 @@ export const zh: Record = { accessibilityUse: '读取界面控件、点击和输入文字', screenRecordingUse: '截取所选应用的窗口', accessibilityPermission: '在系统设置中允许 {application} 读取界面、点击和输入。', screenRecordingPermission: '在系统设置中允许 {application} 截取应用窗口。', - permissionHint: '在系统设置中开启对应应用;若 macOS 提示其他应用,以系统提示为准', - permissionHintUnknown: '在系统设置中,为 macOS 提示的应用开启对应权限', + permissionHint: '在系统设置中管理 macOS 提示的应用权限', granted: '已允许', unknown: '未确认', request: '申请权限', settings: '打开系统设置', retry: '重试', allowedApps: '始终允许的应用', blockedApps: '不允许访问的应用', retryApproval: '允许再次询问', diff --git a/packages/experimental/cua/src/client/mount.ts b/packages/experimental/cua/src/client/mount.ts index bc43e9bd8b..ab0d350c68 100644 --- a/packages/experimental/cua/src/client/mount.ts +++ b/packages/experimental/cua/src/client/mount.ts @@ -34,6 +34,15 @@ export async function mountCuaPermissions(ctx: Context, contribution: TypertRemo ctx.effect(() => ctx.locale.register(NS, { en, zh })) const controller = new PermissionController(ctx.remote.cuaAccess) const face = controller.face() + ctx.inject(['pluginChanges'], (scope) => { + const changes = scope.pluginChanges.bundle('@deepseek-ai/dsh-cua-bundle') + const synchronize = () => { controller.setChanging(changes.getSnapshot()) } + synchronize() + scope.effect(() => { + const unsubscribe = changes.subscribe(synchronize) + return () => { unsubscribe(); controller.setChanging(false) } + }) + }) const preset = ctx.configForms.get<{ default: string; selectedDefault?: string }>('agent-preset-registry') const settings = { ...face, diff --git a/packages/experimental/cua/tests/controller.client.spec.ts b/packages/experimental/cua/tests/controller.client.spec.ts index 7a2f146ca2..ddb76111bc 100644 --- a/packages/experimental/cua/tests/controller.client.spec.ts +++ b/packages/experimental/cua/tests/controller.client.spec.ts @@ -124,6 +124,7 @@ it('ignores stale toast completion after the permission UI is disposed', async ( await f.controller.dispose() const before = f.controller.state.getSnapshot() f.face.dismissToast() + f.controller.setChanging(false) f.face.refresh() expect(f.controller.state.getSnapshot()).toBe(before) }) @@ -211,6 +212,52 @@ it('keeps a user-action failure visible when a refresh is queued behind it', asy }) +it.each(['success', 'failure'])('drops a permission read %s from the previous bundle activation', async (outcome) => { + const f = fixture() + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + const pending = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(async () => { pending.resolve(value); await f.controller.dispose() }) + f.actions.systemStatus.mockImplementationOnce(async () => { + entered.resolve(undefined) + return { ok: true, value: await pending.promise } + }) + f.face.refresh() + await entered.promise + f.controller.setChanging(true) + f.controller.setChanging(true) + expect(f.controller.state.getSnapshot()).toMatchObject({ busy: true, system: { error: null, value } }) + f.face.request('accessibility') + expect(f.actions.requestPermission).not.toHaveBeenCalled() + if (outcome === 'failure') pending.reject(new Error('old helper stopped')) + else pending.resolve({ ...value, screenRecording: true }) + await Promise.allSettled([pending.promise]) + expect(f.controller.state.getSnapshot()).toMatchObject({ busy: true, system: { error: null, value } }) + f.controller.setChanging(false) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()).toMatchObject({ system: { error: null, value } }) + expect(f.actions.systemStatus).toHaveBeenCalledTimes(3) +}) + +it.each(['success', 'failure'])('does not publish a user-action %s after its bundle is replaced', async (outcome) => { + const f = fixture(), pending = Promise.withResolvers(), entered = Promise.withResolvers() + onTestFinished(async () => { pending.resolve(undefined); await f.controller.dispose() }) + f.actions.requestPermission.mockImplementationOnce(async () => { + entered.resolve(undefined) + await pending.promise + return { ok: true, value } + }) + f.face.request('accessibility') + await entered.promise + f.controller.setChanging(true) + if (outcome === 'failure') pending.reject(new Error('old request cancelled')) + else pending.resolve(undefined) + f.controller.setChanging(false) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot()).toMatchObject({ toast: null, system: { error: null, value } }) + expect(f.actions.systemStatus).toHaveBeenCalledOnce() +}) + it('publishes each observation before the other queries finish', async () => { const f = fixture() const system = Promise.withResolvers() @@ -249,6 +296,20 @@ it('keeps application decisions available when system inspection fails', async ( await f.controller.dispose() }) +it.each(['success', 'failure'])('drops old icon %s after disabling and reenabling the bundle', async (outcome) => { + const f = fixture(), pending = Promise.withResolvers>() + onTestFinished(async () => { pending.resolve({}); await f.controller.dispose() }) + f.actions.applicationIcons.mockImplementationOnce(async () => ({ ok: true, value: await pending.promise })) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().system.busy).toBe(false) }) + f.controller.setChanging(true) + f.controller.setChanging(false) + if (outcome === 'success') pending.resolve({ '/old/app': 'old-icon' }) + else pending.reject(new Error('old helper stopped')) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + expect(f.controller.state.getSnapshot().icons).toEqual({ value: {}, error: null, busy: false }) +}) + it.each([ ['ptcConfiguration', 'runtime'], ['systemStatus', 'system'], @@ -279,3 +340,27 @@ it.each(['requestPermission', 'openSettings', 'revoke', 'retryApproval'] as cons expect(f.actions.systemStatus).toHaveBeenCalledOnce() }, ) + + +it('preserves settled failures and values while all four observations are paused', async () => { + const f = fixture() + const error = new Error('unavailable') + f.actions.ptcConfiguration.mockRejectedValueOnce(error) + f.actions.systemStatus.mockRejectedValueOnce(error) + f.actions.applications.mockRejectedValueOnce(error) + f.actions.applicationIcons.mockRejectedValueOnce(error) + f.face.refresh() + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + f.controller.setChanging(true) + for (const key of ['runtime', 'system', 'applications', 'icons'] as const) { + expect(f.controller.state.getSnapshot()[key]).toMatchObject({ error: 'unavailable', busy: true }) + } + f.face.refresh() + expect(f.actions.systemStatus).toHaveBeenCalledOnce() + f.controller.setChanging(false) + await vi.waitFor(() => { expect(f.controller.state.getSnapshot().busy).toBe(false) }) + for (const key of ['runtime', 'system', 'applications', 'icons'] as const) { + expect(f.controller.state.getSnapshot()[key].error).toBeNull() + } + await f.controller.dispose() +}) diff --git a/packages/experimental/cua/tests/mount.client.spec.ts b/packages/experimental/cua/tests/mount.client.spec.ts index 02145516f6..0ce5391c79 100644 --- a/packages/experimental/cua/tests/mount.client.spec.ts +++ b/packages/experimental/cua/tests/mount.client.spec.ts @@ -36,6 +36,11 @@ async function fixture(fail = false) { $on(_event: string, listener: (namespace: string, revision: number) => void) { settingsListener = listener; return unsubscribe } } new Remote() + const changes = createSnapshotStore(false) + ctx.provide('pluginChanges', { bundle: (name) => { + expect(name).toBe('@deepseek-ai/dsh-cua-bundle') + return changes + } }) const setDefault = vi.fn(async () => true) const setPersistent = vi.fn<() => Promise>>().mockResolvedValue({ ok: true, value: undefined }) const runtimeConfiguration = vi.fn<() => Promise>>() @@ -64,7 +69,7 @@ async function fixture(fail = false) { 'plugins.bundle.config': { kind: 'keyed', scope: 'root' }, 'shell.overlay': { kind: 'list', scope: 'root' }, } } as never, () => null) if (fail) vi.spyOn(ctx.slots, 'inject').mockImplementationOnce(() => { throw new Error('slot unavailable') }) - return { ctx, unmount, unsubscribe, status, systemStatus, applicationIcons, + return { ctx, changes, unmount, unsubscribe, status, systemStatus, applicationIcons, requestPermission, openSettings, revoke, retryApproval, setDefault, setPersistent, runtimeConfiguration, updateSettings(namespace: string) { settingsListener!(namespace, 1) }, } @@ -190,3 +195,26 @@ it('refreshes only the settings namespace reported by the permission owner', asy await vi.waitFor(() => { expect(f.status).toHaveBeenCalledTimes(2) }) } finally { await f.ctx.fiber.dispose() } }) + + +it('resumes queries when a management operation settles without a mounted permission page', async () => { + const f = await fixture() + try { + f.changes.set(true) + const fiber = await f.ctx.plugin({ inject: [...inject], apply: ctx => mountCuaPermissions(ctx, contribution) }) + const actions = f.ctx.slots.entries('plugins.bundle.config')[0]!.inject!() + actionsOf(actions) + expect(actions.hooks.cuaPermissions.getSnapshot().changing).toBe(true) + actions.refresh() + f.ctx.emit('connection/reset') + await setImmediate() + expect(f.status).not.toHaveBeenCalled() + f.changes.set(false) + await vi.waitFor(() => { expect(actions.hooks.cuaPermissions.getSnapshot().busy).toBe(false) }) + expect(f.status).toHaveBeenCalledOnce() + f.changes.set(true) + await fiber.dispose() + f.changes.set(false) + expect(f.status).toHaveBeenCalledOnce() + } finally { await f.ctx.fiber.dispose() } +}) diff --git a/packages/experimental/cua/tests/panel.client.spec.tsx b/packages/experimental/cua/tests/panel.client.spec.tsx index 6c2b06668f..c73f46a8eb 100644 --- a/packages/experimental/cua/tests/panel.client.spec.tsx +++ b/packages/experimental/cua/tests/panel.client.spec.tsx @@ -24,7 +24,7 @@ function fixture() { const state = createSnapshotStore({ runtime: { value: { persistent: false, writable: true }, busy: false, error: null }, system: { value: null, busy: true, error: null }, applications: { value: null, busy: true, error: null }, - icons: { value: null, busy: true, error: null }, busy: true, toast: null, + icons: { value: null, busy: true, error: null }, busy: true, changing: false, toast: null, }) const preset = createSnapshotStore>({ status: 'ready', value: { default: 'standard', selectedDefault: 'ptc' }, @@ -134,7 +134,7 @@ it('shows query failures as unknown while retaining previously displayed apps', fireEvent.click(screen.getByRole('button', { name: en.retry })) expect(f.props.refresh).toHaveBeenCalledTimes(2) f.publish({ system: { ...f.state.getSnapshot().system, busy: true }, busy: true }) - expect(screen.queryByRole('button', { name: en.retry })).toBeNull() + expect(screen.getByRole('button', { name: en.retry }).hasAttribute('disabled')).toBe(true) f.publish({ system: { ...f.state.getSnapshot().system, busy: false, error: null }, busy: false }) expect(screen.queryByRole('button', { name: en.retry })).toBeNull() f.publish(observe(value)) @@ -241,7 +241,7 @@ it.each([en, zh])('shows four requirements with the target application beside ea expect(within(requirements).getByText(dictionary.permissionHint)).toBeTruthy() f.publish({ system: { ...f.state.getSnapshot().system, error: 'offline' } }) expect(screen.queryByText(application.name)).toBeNull() - expect(within(requirements).getByText(dictionary.permissionHintUnknown)).toBeTruthy() + expect(within(requirements).getByText(dictionary.permissionHint)).toBeTruthy() }) it('names the authorization target even when its icon is unavailable', () => { @@ -265,12 +265,12 @@ it('waits for the first current observation instead of opening setup with a stal }) -it('omits setup instructions once both system permissions are granted', () => { +it('keeps permission management guidance after both permissions are granted', () => { const f = fixture() render() f.publish({ ...observe({ ...value, system: { ...value.system, accessibility: true, screenRecording: true } }) }) expect(screen.getAllByText(en.granted)).toHaveLength(2) - expect(screen.queryByText(en.permissionHintUnknown)).toBeNull() + expect(screen.getByText(en.permissionHint)).toBeTruthy() }) @@ -284,6 +284,26 @@ it('keeps all four requirements visible before the first permission response', ( for (const permission of [en.accessibility, en.screenRecording]) expect(within(screen.getByRole('group', { name: permission })).queryByRole('button')).toBeNull() }) +it('keeps content mounted and disables actions while management changes are pending', () => { + const f = fixture() + f.publish({ ...observe(value), system: { value: value.system, busy: false, error: 'provider is reloading' }, busy: false }) + f.publish({ changing: true }) + const view = render() + const requirements = screen.getByRole('region', { name: en.title }) + expect(within(requirements).getAllByRole('group')).toHaveLength(4) + expect(screen.getByRole('alert').textContent).toContain('provider is reloading') + expect(within(screen.getByRole('group', { name: en.accessibility })).getByRole('button', { name: en.unknown }).hasAttribute('disabled')).toBe(true) + fireEvent.focus(window) + expect(f.props.refresh).toHaveBeenCalledTimes(2) + f.publish({ system: { ...f.state.getSnapshot().system, busy: true, error: null }, busy: true }) + f.publish({ changing: false }) + view.rerender() + expect(screen.getByRole('region', { name: en.title })).toBe(requirements) + expect(f.props.refresh).toHaveBeenCalledTimes(2) + f.publish({ system: { ...f.state.getSnapshot().system, busy: false }, busy: false }) + expect(screen.getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(false) +}) + it('fills application names, system permissions, and icons as their observations arrive', () => { const f = fixture() render() @@ -312,7 +332,7 @@ it('fills application names, system permissions, and icons as their observations it.each(['applications', 'icons'] as const)('contains a failed %s query in the application section', (key) => { const f = fixture() f.publish({ ...observe(value), [key]: { value: null, busy: false, error: 'offline' } }) - render() + const view = render() const requirements = screen.getByRole('region', { name: en.title }) const access = screen.getByRole('region', { name: en.applicationsTitle }) expect(within(requirements).getByText(en.granted)).toBeTruthy() @@ -320,6 +340,9 @@ it.each(['applications', 'icons'] as const)('contains a failed %s query in the a expect(within(access).getByRole('alert').textContent).toContain('offline') fireEvent.click(within(access).getByRole('button', { name: en.retry })) expect(f.props.refresh).toHaveBeenCalledTimes(2) + f.publish({ changing: true }) + view.rerender() + expect(within(access).getByRole('alert').textContent).toContain('offline') }) it('finishes activation guidance without waiting for application icons', () => { @@ -365,12 +388,32 @@ it('loads the runtime setting independently and reports unavailable and read-onl }) -it('retains the previous single control while a stale query error is being refreshed', () => { +it('retains uncertainty and the diagnostic until a refresh succeeds', () => { const f = fixture() f.publish(observe(value)) render() f.publish({ system: { value: value.system, busy: true, error: 'stale error' }, busy: true }) - expect(screen.getByRole('button', { name: en.request }).hasAttribute('disabled')).toBe(true) - expect(screen.getByRole('button', { name: en.granted }).hasAttribute('disabled')).toBe(true) - expect(screen.queryByRole('alert')).toBeNull() + for (const button of screen.getAllByRole('button', { name: en.unknown })) expect(button.hasAttribute('disabled')).toBe(true) + expect(screen.getByRole('alert').textContent).toContain('stale error') +}) + + +it('retains a known read-only configuration and query failure throughout management and refresh', () => { + const f = fixture() + f.preset.set({ ...f.preset.getSnapshot(), value: { default: 'standard' }, writable: false }) + f.publish({ ...observe(value), runtime: { value: { persistent: false, writable: false }, busy: false, error: null }, + applications: { value, busy: false, error: 'offline' } }) + const view = render() + const readonly = screen.getByText(en.persistentReadOnly) + const defaultReadonly = screen.getByText(en.defaultReadOnly) + act(() => { f.preset.set({ ...f.preset.getSnapshot(), status: 'loading' }) }) + const query = screen.getByText(en.applicationsFailed.replace('{message}', 'offline')) + f.publish({ changing: true, busy: true, + runtime: { ...f.state.getSnapshot().runtime, busy: true }, + applications: { ...f.state.getSnapshot().applications, busy: true } }) + expect(screen.getByText(en.persistentReadOnly)).toBe(readonly) + expect(screen.getByText(en.defaultReadOnly)).toBe(defaultReadonly) + expect(screen.getByText(en.applicationsFailed.replace('{message}', 'offline'))).toBe(query) + view.unmount() + expect(f.state.getSnapshot().changing).toBe(true) }) diff --git a/packages/experimental/inspector/src/client/bottom/page.module.css b/packages/experimental/inspector/src/client/bottom/page.module.css index f7357b843d..707f5c2b89 100644 --- a/packages/experimental/inspector/src/client/bottom/page.module.css +++ b/packages/experimental/inspector/src/client/bottom/page.module.css @@ -39,11 +39,16 @@ user-select: none; } -.resize:hover, .resize:focus-visible { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .resize:hover { + background: var(--dsw-alias-interactive-bg-hover); + } +} + .page { display: block; flex: 1; diff --git a/packages/experimental/session-inspector/src/client/views/inspector.module.css b/packages/experimental/session-inspector/src/client/views/inspector.module.css index 196a246277..bda483a019 100644 --- a/packages/experimental/session-inspector/src/client/views/inspector.module.css +++ b/packages/experimental/session-inspector/src/client/views/inspector.module.css @@ -39,21 +39,33 @@ .typeColumn { width: 190px; } .typeHeader { display: flex; align-items: center; gap: 6px; } .typeFilterButton { display: inline-flex; border: none; border-radius: 3px; padding: 2px; background: transparent; color: inherit; cursor: pointer; } -.typeFilterButton:hover, .typeFilterButton[aria-pressed='true'] { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } +.typeFilterButton[aria-pressed='true'] { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .typeFilterButton:hover { color: var(--dsw-alias-state-business-primary); background: var(--dsw-alias-interactive-bg-hover); } +} .typeFilterPanel { position: fixed; z-index: 1250; width: 290px; max-width: calc(100vw - 24px); max-height: calc(100vh - 24px); overflow: auto; box-sizing: border-box; padding: 10px; color: var(--dsw-alias-label-primary); font-size: 11px; } .typeFilterInput { width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; background: transparent; color: inherit; font: inherit; } .typeFilterHelp { margin: 6px 0; color: var(--dsw-alias-label-secondary); } .typeFilterOptions { max-height: 180px; overflow: auto; } .typeFilterOptions button { display: block; width: 100%; padding: 4px 6px; border: none; background: transparent; color: inherit; font: inherit; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } -.typeFilterOptions button:hover, .typeFilterOptions button[aria-selected='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +.typeFilterOptions button[aria-selected='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +@media (hover: hover) { + .typeFilterOptions button:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +} .typeFilterActions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 8px; } .typeFilterActions button { border: 0.5px solid var(--dsw-alias-border-l2); border-radius: 4px; padding: 3px 6px; background: transparent; color: inherit; font: inherit; cursor: pointer; } .contextRow .record { color: var(--dsw-alias-label-tertiary); } .locationColumn { width: 65px; } .timeColumn { width: 100px; } -.table tr:hover, .selected { background: var(--dsw-alias-interactive-bg-hover); } +.selected { background: var(--dsw-alias-interactive-bg-hover); } +@media (hover: hover) { + .table tr:hover { background: var(--dsw-alias-interactive-bg-hover); } +} .sticky { position: sticky; } -.table .sticky, .table .sticky:hover { background: var(--dsw-alias-bg-module-platform); } +.table .sticky { background: var(--dsw-alias-bg-module-platform); } +@media (hover: hover) { + .table .sticky:hover { background: var(--dsw-alias-bg-module-platform); } +} .sticky.selected .record { color: var(--dsw-alias-state-business-primary); } .identity { color: var(--dsw-alias-label-tertiary); } .identityText { display: block; overflow: hidden; text-overflow: ellipsis; } @@ -62,12 +74,19 @@ .preview { display: block; width: 100%; overflow: hidden; text-overflow: ellipsis; } .details { display: flex; flex: 0 0 40%; flex-direction: column; min-height: 0; max-height: 80%; overflow: hidden; } .detailsResize { flex: none; height: 5px; cursor: row-resize; touch-action: none; user-select: none; background: var(--dsw-alias-border-l2); } -.detailsResize:hover, .detailsResize:active, .detailsResize:focus-visible { background: var(--dsw-alias-state-business-primary); } +.detailsResize:active, +.detailsResize:focus-visible { background: var(--dsw-alias-state-business-primary); } +@media (hover: hover) { + .detailsResize:hover { background: var(--dsw-alias-state-business-primary); } +} .details pre { flex: 1; min-height: 0; margin: 0; padding: 8px; font-size: inherit; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; } .modeSelector { border: none; background: var(--dsw-alias-bg-module-platform); color: inherit; font: inherit; padding: 2px 4px; } .modeControls { display: inline-flex; align-items: center; gap: 6px; vertical-align: middle; } .pickButton { display: inline-flex; align-items: center; justify-content: center; border-radius: 4px; padding: 3px; } -.pickButton:hover, .pickButton[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +.pickButton[aria-pressed='true'] { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +@media (hover: hover) { + .pickButton:hover { background: var(--dsw-alias-interactive-bg-hover); color: var(--dsw-alias-state-business-primary); } +} .pickNotice { flex: none; padding: 4px 8px; color: var(--dsw-alias-label-secondary); border-bottom: 0.5px solid var(--dsw-alias-border-l1); } .chatPick { position: fixed; z-index: 1201; pointer-events: none; background: color-mix(in srgb, var(--dsw-alias-state-business-primary) 15%, transparent); outline: 1px solid var(--dsw-alias-state-business-primary); border-radius: 4px; } .chatPickMask { position: fixed; z-index: 1200; pointer-events: auto; background: transparent; cursor: crosshair; touch-action: none; } @@ -80,7 +99,9 @@ .objectValue { color: var(--dsw-alias-label-secondary); } .objectMuted { color: var(--dsw-alias-label-tertiary); } .objectReference, .objectBreadcrumbs button { border: none; background: transparent; padding: 0; color: var(--dsw-alias-state-business-primary); cursor: pointer; font: inherit; text-align: left; } -.objectReference:hover { text-decoration: underline; } +@media (hover: hover) { + .objectReference:hover { text-decoration: underline; } +} .objectBreadcrumbs { position: sticky; top: -8px; z-index: 1; margin: -8px -8px 8px; padding: 6px 8px; overflow: auto; background: var(--dsw-alias-bg-module-platform); border-bottom: 0.5px solid var(--dsw-alias-border-l1); } .objectBreadcrumbs ol { display: flex; align-items: center; gap: 6px; list-style: none; margin: 0; padding: 0; } .objectBreadcrumbs li { display: flex; align-items: center; gap: 6px; flex: none; } diff --git a/packages/experimental/session-inspector/tests/table.client.spec.tsx b/packages/experimental/session-inspector/tests/table.client.spec.tsx index ee46736bdb..791f9cafc2 100644 --- a/packages/experimental/session-inspector/tests/table.client.spec.tsx +++ b/packages/experimental/session-inspector/tests/table.client.spec.tsx @@ -162,12 +162,12 @@ it('shows the complete row identity in a tooltip outside the table clipping area } const ui = render() const id = ui.getByText(identity) - fireEvent.mouseEnter(id) + fireEvent.pointerEnter(id) const tooltip = ui.getByRole('tooltip', { hidden: true }) expect(tooltip.textContent).toBe(identity) expect(tooltip.parentElement).toBe(document.body) expect(ui.container.contains(tooltip)).toBe(false) - fireEvent.mouseLeave(id) + fireEvent.pointerLeave(id) expect(ui.queryByRole('tooltip', { hidden: true })).toBeNull() }) diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts index be518c2952..9cfeb80514 100644 --- a/packages/extensions/tool-cordis/src/api-catalog.ts +++ b/packages/extensions/tool-cordis/src/api-catalog.ts @@ -81,20 +81,6 @@ export interface TypeApiEntry { /** Every harness `ctx.` service, sorted by key. */ export const SERVICE_API: readonly ServiceApiEntry[] = [ - { - key: 'agentController', - summary: 'Delivers the messages that senders, such as the Web Session Remote, bring on someone\'s behalf.', - description: 'Delivers the messages that senders, such as the Web Session Remote, bring on someone\'s behalf. Messages to one Agent are prepared, decided, and appended one at a time in arrival order; messages to different Agents do not wait for each other. Disposal stops every message that has not entered an inbox and finishes once each delivery it stopped has settled.', - methods: [ - { - signature: 'async deliver(agent: Agent, request: DeliveryRequest): Promise', - description: 'Deliver one message from outside the Agent: prepare its content, ask the `agent-controller/admission` stage for a decision, then append an admitted message with `followup` or `steer`.', - parameters: [{ name: 'agent', description: 'the receiving Agent.' }, { name: 'request', description: 'delivery mode, source, cancellation, and the sender\'s content preparation.' }], - returns: 'the id of the message once it is in the inbox.', - throws: ['{@link AgentAdmissionRejected} when a listener rejects the message or throws, or when the Agent left the registry first; the error of `prepare`, `beforeInsert`, `followup`, or `steer` unchanged; the sender\'s cancellation reason as soon as it cancels, also while the message waits behind another one; the stop reason once the controller stops.'], - }, - ], - }, { key: 'agentDefaultModel', summary: 'Owns the default model selection independently of any Host or transport.', @@ -2301,10 +2287,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: '@Remote(\'prompt\') async prompt(request: SessionPromptRequest, signal: AbortSignal): Promise', - description: 'Admit one prompt after explicitly resuming its Session. The prompt is delivered through `ctx.agentController`, whose `agent-controller/admission` stage runs before it enters the inbox, so the call can wait for a listener that holds it.', - parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation; it also stops a prompt waiting for or held at the admission stage.' }], + description: 'Admit one prompt after explicitly resuming its Session.', + parameters: [{ name: 'request', description: 'Session identity, prompt content, source metadata, and delivery mode.' }, { name: 'signal', description: 'caller cancellation before prompt admission begins.' }], returns: 'acknowledgement that the Agent accepted the prompt.', - throws: ['`session/prompt-rejected` when an admission listener refuses the prompt or fails.'], }, { signature: '@Remote(\'attachment\') attachment(request: SessionAttachmentRequest): Promise', @@ -4152,14 +4137,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ /** Every harness event, sorted by name. */ export const EVENT_API: readonly EventApiEntry[] = [ - { - name: 'agent-controller/admission', - mode: 'waterfall', - signature: '\'agent-controller/admission\'(this: Scoped, payload: { agent: Agent; message: UserMessage; signal: AbortSignal }, next: () => Promise): Promise', - summary: 'Decide whether one message from outside an Agent enters its inbox.', - description: 'Decide whether one message from outside an Agent enters its inbox. `ctx.agentController.deliver` runs this stage and appends the message only after the whole chain admitted it; messages the Agent\'s own machinery produces enter through `followup`, `steer`, or `inject` without it. Listeners run in registration order, and messages to one Agent are decided one at a time in arrival order. A listener admits by returning the decision of `next()`, whose last step admits; awaiting before `next()` holds the message, and returning a `reject` decision refuses it without asking later listeners. A thrown error means the listener\'s check itself failed. A listener must not wait for a person when the message source is unattended, such as a schedule or a webhook.', - parameters: [{ name: 'payload', description: '.signal - aborted when the sender cancels or the controller stops; a holding listener must settle once it aborts, because controller disposal waits for it. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): Agent-scoped listeners receive only that Agent.' }], - }, { name: 'agent-loop/config-start-failed', mode: 'emit', @@ -4880,10 +4857,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AdapterRegistrationHandle', declaration: 'export interface AdapterRegistrationHandle {\n (): void;\n replace(providers: string[]): void;\n}', }, - { - name: 'AdmissionDecision', - declaration: 'export type AdmissionDecision = {\n readonly kind: \'admit\';\n} | {\n readonly kind: \'reject\';\n readonly reason: string;\n readonly message: string;\n readonly presented: boolean;\n};', - }, { name: 'AdmittedPromptContentPart', declaration: 'export type AdmittedPromptContentPart = {\n readonly type: \'text\';\n readonly text: string;\n} | {\n readonly type: \'image\';\n readonly attachment: ImageAttachmentRef;\n} | {\n readonly type: \'file\';\n readonly attachment: FileAttachmentRef;\n};', @@ -5544,14 +5517,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'DeepSeekLlmApiJson', declaration: 'export type DeepSeekLlmApiJson = null | boolean | number | string | DeepSeekLlmApiJson[] | {\n [key: string]: DeepSeekLlmApiJson;\n};', }, - { - name: 'DeliveryMode', - declaration: 'export type DeliveryMode = \'followup\' | \'steer\';', - }, - { - name: 'DeliveryRequest', - declaration: 'export interface DeliveryRequest {\n readonly mode: DeliveryMode;\n readonly source: MessageSource;\n readonly signal: AbortSignal;\n readonly prepare: (signal: AbortSignal) => Promise;\n readonly beforeInsert?: (message: UserMessage) => void;\n}', - }, { name: 'DeliveryRetentionBounds', declaration: 'export interface DeliveryRetentionBounds {\n readonly days: number;\n readonly records: number;\n}', @@ -6436,10 +6401,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'MessageRoleMap', declaration: 'export interface MessageRoleMap {\n system: SystemMessage;\n developer: DeveloperMessage;\n user: UserMessage;\n assistant: AssistantMessage;\n tool: ToolResultMessage;\n}', }, - { - name: 'MessageSource', - declaration: 'export type MessageSource = MessageSourceMap[keyof MessageSourceMap];', - }, { name: 'MessageSourceMap', declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n model: ModelMessageSource;\n tool: ToolMessageSource;\n \'system-prompt\': SystemPromptMessageSource;\n}', diff --git a/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css b/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css index ca9e03ae14..23f9a7ab4e 100644 --- a/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisDefineRow.module.css @@ -177,9 +177,11 @@ font: var(--dsw-font-xs-13); } -.sourceTab:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .sourceTab:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-primary); + } } .sourceTab:disabled { @@ -270,14 +272,19 @@ transition: opacity 100ms ease; } -.card:hover .inspectButton, .inspectButton:focus-visible { opacity: 1; } -.inspectButton:hover { - background: var(--dsw-alias-interactive-bg-hover-solid); - color: var(--dsw-alias-label-primary); +@media (hover: hover) { + .card:hover .inspectButton { + opacity: 1; + } + + .inspectButton:hover { + background: var(--dsw-alias-interactive-bg-hover-solid); + color: var(--dsw-alias-label-primary); + } } .visuallyHidden { @@ -294,3 +301,9 @@ transition: none; } } + +@media (hover: none) { + .inspectButton { + opacity: 1; + } +} diff --git a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css index 8bedbf169e..dbcc017f74 100644 --- a/packages/extensions/ui-cordis/src/client/CordisPanel.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisPanel.module.css @@ -34,8 +34,10 @@ overflow: hidden; } -.badge:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .badge:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .badge[data-active] { @@ -296,8 +298,10 @@ cursor: pointer; } -.transitionActions button:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .transitionActions button:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + } } .transitionActions button:disabled { @@ -328,9 +332,11 @@ cursor: pointer; } -.actionButton:hover:not(:disabled) { - background: var(--dsw-alias-interactive-bg-hover); - color: var(--dsw-alias-label-secondary); +@media (hover: hover) { + .actionButton:hover:not(:disabled) { + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + } } .actionButton:disabled { diff --git a/packages/extensions/ui-cordis/src/client/CordisRunRow.module.css b/packages/extensions/ui-cordis/src/client/CordisRunRow.module.css index 3f545b01fb..71db7c7ea2 100644 --- a/packages/extensions/ui-cordis/src/client/CordisRunRow.module.css +++ b/packages/extensions/ui-cordis/src/client/CordisRunRow.module.css @@ -89,13 +89,18 @@ opacity: 0; } -.card:hover .inspect, .inspect:focus-visible { opacity: 1; } -.inspect:hover { - background: var(--dsw-alias-interactive-bg-hover); +@media (hover: hover) { + .card:hover .inspect { + opacity: 1; + } + + .inspect:hover { + background: var(--dsw-alias-interactive-bg-hover); + } } .message { @@ -132,3 +137,9 @@ border: none; border-radius: 0; } + +@media (hover: none) { + .inspect { + opacity: 1; + } +} diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index 1b3e26ebda..38662a9882 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -69,14 +69,14 @@ en: e7348db3f7115960 zh: 5fd5fb09d7d09982 /deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/what-the-model-sees: - en: 948e7c8ec2ffda92 - zh: 72bd6c7a4691a355 + en: a1c686712365d7ae + zh: f28c4e2373013a2a /deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/token-effect: en: 820221e8ae37aa61 zh: 7e6ff4db10067fc0 /deepseek-ai-dsh-llm-pi-ai/model-experience/provider-request-through-pi-ai/kv-cache-effect: - en: 8c1f842a95ab46fb - zh: d6dd124c82f1d96b + en: 7a2595060340d7d5 + zh: e4ba753dcdaa89fc /deepseek-ai-dsh-llm-pi-ai/model-experience/provider-response: en: 802d7ff60a1d03ff zh: c41c56e28f3e8680 @@ -90,8 +90,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: a8b6c192acf56f83 - zh: bb6b3ce40425342f + en: 1c54b949c81df84f + zh: b894d01aa3e6afc1 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: 3678dd0f6642c2a5 zh: 75cbdd064cc64282 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index b2ef4c7a2e..0dcf15b97a 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -209,7 +209,7 @@ Read these pages when the package-level contract is not enough. They move from t #### What the model sees -The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order; pi-ai owns protocol-specific placement and concatenates them at the beginning for models without mid-conversation system support. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. +The selected catalog model receives system-message text, conversation history, tools, and supported sampling fields. The adapter preserves later system messages in history order when the catalog model declares `supportsMidConvoSystemMessages`. Otherwise, it converts each non-empty later system message to a user message wrapped in `` before passing it to pi-ai. The wrapper does not grant system-role priority. One-shot `GenerateOptions.system` precedes the initial history prompt. Repeated system messages remain repeated; empty system messages are skipped without discarding earlier text. Tool-update capabilities select the shared historical declaration projection; pi-ai receives initially active tools and grouped developer updates carrying additions and supported removals. When every projected declaration of an Anthropic request with tool updates is deferred, the initially active tools are one placeholder, `DeferredToolPlaceholder`, whose description tells the model never to call it; a call reaches the ordinary unknown-tool result. Anthropic receives later tool definitions inline in the update message. Replay preserves tool-call namespaces for dynamically loaded Responses tools. Each retained image is preceded by text naming its complete attachment id and actual request dimensions. When the current execution filesystem maps the attachment provider's host object, the text also carries a read-only normalized-object path and warns that normalization or request projection may have resized or re-encoded the upload. Each occurrence selected by a logged image-offload decision keeps its own identity and currently resolved access in replacement text, and its normalized attachment is not read or transformed. When the retained occurrences' exact base64 payload still exceeds the route's `maxRequestImageBytes`, the call fails with `IMAGE_OFFLOAD_REQUIRED` so `dsh-compaction-image-offload` records the selected occurrences in an `image/offload` event and retries the step. Provider-native replay metadata is restored only when the adapter validates it for the historical content. `imageRequestTarget(provider, model, image)` exposes the same request-image dimensions and byte target to screenshot consumers; adapters without image resizing preserve the normalized source. #### Token effect @@ -217,7 +217,7 @@ Provider tokenization governs exact input. Retained images add the stable attach #### KV Cache effect -Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards. +Later system messages supplied to the adapter stay out of the initial prompt, preserving the preceding request prefix. This does not change the agent loop’s prompt admission, which consolidates updates on routes without native system-update support. Conversion preserves logical request order, while image handles and offload placeholders add model-visible text. A changed execution-world path rewrites a historical handle and can prevent reuse from that image even when attachment identity and request bytes stay stable. Changing adapter instance, provider, model, or another upstream token has the same suffix effect. An offload decision turns an earlier image into placeholder text, so reuse ends at that message; the omission never reverts, so the prefix stays stable afterwards. ### Provider response @@ -253,7 +253,7 @@ These limits define where the adapter stops and future work begins. They are cur - **A modality declaration is not verified** — a model declaring `image` its gateway does not serve is refused by the provider after prompt admission. The durable image remains in history and the same misdeclared model can fail again; switching to a text-only model remains possible because the shared LLM runtime projects image references into stable text for that request. - **An unauthenticated route depends on its protocol** — a route naming no credential resolves as configured-but-keyless, but pi-ai's OpenAI-compatible implementation still requires an API key or an `Authorization` header, so a keyless local server needs a placeholder credential referenced by `apiKeyEnv` or an `Authorization` entry in `headers`. - **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers. -- **Conversation update support comes only from the installed pi-ai catalog** — a route receives in-history prompt and tool updates only when its catalog model declares the mid-conversation compat flags, and profiles cannot set them; hand-declared routes and unflagged models receive the concatenated system messages and the complete current tool list at the start of each request. +- **Conversation update support comes only from the installed pi-ai catalog** — native system and tool updates require the catalog model’s mid-conversation compat flags, which profiles cannot set. Hand-declared routes and unflagged models receive later system text as user-role reminders and the complete current tool list at the start of each request. - **Images are accepted only in user and tool-result messages** — images in system, developer, or assistant history fail with `UNSUPPORTED_CONTENT` on both conversion paths. - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers. - **Retry policy is provider-owned, not an SDK retry** — pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 48c2c44c13..c434554f7d 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -209,7 +209,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts #### 模型看到什么 -所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。适配器按历史顺序保留后续系统消息;pi-ai 负责各协议的消息位置,并为不支持对话中系统消息的模型将它们拼接到开头。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 +所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 #### Token 影响 @@ -217,7 +217,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts #### KV Cache 影响 -转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。 +传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。 ### 提供方响应 @@ -253,7 +253,7 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **模态声明不受校验**——声明 `image` 而其网关不支持的模型会在提示词准入后被提供方拒绝。持久图片仍留在历史中,同一误声明模型可能再次失败;切换到纯文本模型仍然可行,因为共享 LLM 运行时会针对该请求把图片引用投影为稳定文本。 - **未认证路由取决于其协议**——不点名凭据的路由解析为已配置但无密钥,但 pi-ai 的 OpenAI 兼容实现仍要求 API 密钥或 `Authorization` 标头,因此无密钥本地服务器需要由 `apiKeyEnv` 引用或 `headers` 中的 `Authorization` 条目提供的占位凭据。 - **不支持 `GenerateOptions.stop`**——pi-ai 的通用流式选项无法跨提供方保证停止序列行为。 -- **对话更新支持只来自已安装的 pi-ai 目录**——只有当路由的目录模型声明了对话中 compat 标志时,该路由才会在历史中接收提示词与工具更新,且 profile 不能设置这些标志;手工声明的路由与未标记的模型会在每次请求开头接收拼接后的系统消息与完整的当前工具列表。 +- **对话更新支持只来自已安装的 pi-ai 目录**——原生系统与工具更新需要目录模型的对话中 compat 标志,profile 不能设置这些标志。手工声明的路由与未标记的模型会以用户角色提醒接收后续系统文本,并在每次请求开头接收完整的当前工具列表。 - **图片只在 user 与工具结果消息中被接受**——system、developer 或 assistant 历史中的图片在两条转换路径上都会以 `UNSUPPORTED_CONTENT` 失败。 - **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。 - **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index a912fff69f..9c8b4d3f60 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -403,7 +403,7 @@ export class PiAiAdapter extends LlmAdapter { this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) } const context = attachments === undefined - ? toPiContext(request, undefined, onReplayDegrade) + ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history') : await toPiContext({ ...request, signal: watchdog.signal }, { attachments, resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), @@ -412,7 +412,7 @@ export class PiAiAdapter extends LlmAdapter { maxPixels: profile.requestImagePixelBudget, maxBytes: profile.requestImageMaxBytes, }, - }, onReplayDegrade) + }, onReplayDegrade, updates.systemPromptUpdate === 'in-history') const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index c819f44301..f884bb049a 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -219,6 +219,7 @@ function appendSharedHistory( options: GenerateOptions, messages: PiMessage[], toolNames: Map, + supportsMidConvoSystemMessages: boolean, onReplayDegrade?: (reason: string) => void, ): boolean { if (message.role === 'developer') { @@ -227,7 +228,14 @@ function appendSharedHistory( } if (message.role === 'system') { const content = flattenText(message) - if (content.length > 0) messages.push({ role: 'system', content, timestamp: 0 }) + if (content.length > 0) { + if (supportsMidConvoSystemMessages) { + messages.push({ role: 'system', content, timestamp: 0 }) + } else { + const reminder = content.replaceAll('', '<\\/system-reminder>') + messages.push({ role: 'user', content: `\n${reminder}\n`, timestamp: 0 }) + } + } return true } if (message.role === 'assistant') { @@ -237,7 +245,11 @@ function appendSharedHistory( return false } -function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: string) => void): PiContext { +function textOnlyContext( + options: GenerateOptions, + supportsMidConvoSystemMessages: boolean, + onReplayDegrade?: (reason: string) => void, +): PiContext { assertSupportedHistory(options.messages) const split = splitSystemPrompt(options) const toolNames = new Map() @@ -246,7 +258,7 @@ function textOnlyContext(options: GenerateOptions, onReplayDegrade?: (reason: st if (contentHasImage(message.content)) { throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') } - if (appendSharedHistory(message, options, messages, toolNames, onReplayDegrade)) continue + if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue if (message.role === 'tool') { messages.push(toolResultOf(message, toolNames, flattenText(message))) continue @@ -292,6 +304,7 @@ export function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageReque * @param options - projected request; one-shot system text precedes the leading history prompt. * @param images - absent; selects the synchronous conversion. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. + * @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder. * @returns the pi-ai context; `tools` is omitted when the request declares none. * @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message. */ @@ -299,6 +312,7 @@ export function toPiContext( options: GenerateOptions, images?: undefined, onReplayDegrade?: (reason: string) => void, + supportsMidConvoSystemMessages?: boolean, ): PiContext /** * Convert harness history to a pi-ai Context while resolving durable images. @@ -310,26 +324,30 @@ export function toPiContext( * @param options - projected request; one-shot system text precedes the leading history prompt. * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. + * @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( options: GenerateOptions, images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, + supportsMidConvoSystemMessages?: boolean, ): Promise export function toPiContext( options: GenerateOptions, images?: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, + supportsMidConvoSystemMessages = false, ): PiContext | Promise { return images === undefined - ? textOnlyContext(options, onReplayDegrade) - : toPiContextWithImages(options, images, onReplayDegrade) + ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade) + : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade) } async function toPiContextWithImages( options: GenerateOptions, images: PiImageRequestContext, + supportsMidConvoSystemMessages: boolean, onReplayDegrade?: (reason: string) => void, ): Promise { const { attachments, resolveImageAccess, maxRequestImageBytes } = images @@ -362,7 +380,7 @@ async function toPiContextWithImages( const messages: PiMessage[] = [] for (const message of exactMessages) { - if (appendSharedHistory(message, options, messages, toolNames, onReplayDegrade)) continue + if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue if (message.role === 'tool') { messages.push(toolResultOf(message, toolNames, userContent(message.content, requestImages, resolveImageAccess))) continue diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index b486b957f1..f5f5f217ed 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -500,7 +500,7 @@ describe('pi-ai system prompt source', () => { code: 'UNSUPPORTED_CONTENT', message: 'pi-ai cannot represent an image in an in-history system message', } - expect(() => toPiContext(options)).toThrow(error.message) + expect(() => toPiContext(options, undefined, undefined, true)).toThrow(error.message) const readImageRequest = vi.fn() await expect(toPiContext(options, imageContext(projectionStore(readImageRequest)))).rejects.toMatchObject(error) expect(readImageRequest).not.toHaveBeenCalled() @@ -511,19 +511,57 @@ describe('pi-ai system prompt source', () => { const expected = { systemPrompt: 'lead rule', messages: [ { role: 'user', content: 'hi', timestamp: 0 }, ] } + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(toPiContext({ ...base, system: 'lead rule', messages: [question] })) + }) + + it('wraps later system text without changing the prefix on both conversion paths', async () => { + const prefix: GenerateOptions = { ...base, system: 'direct', messages: [leading, question] } + const update = history('system', [ + { type: 'text', text: 'First source.' }, { type: 'text', text: '' }, + { type: 'text', text: 'Second source.' }, + ]) + const options = { ...prefix, messages: [...prefix.messages, update, history('system', []), update] } + const reminder = { role: 'user', content: '\nFirst source.Second source.\n', timestamp: 0 } + const before = toPiContext(prefix) + const expected = { ...before, messages: [...before.messages, reminder, reminder] } expect(toPiContext(options)).toEqual(expected) await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) - expect(toPiContext(options)).toEqual(toPiContext({ ...base, system: 'lead rule', messages: [question] })) + expect(options.messages[2]).toBe(update) + expect(update.role).toBe('system') + expect(toPiContext({ ...base, messages: [question, update] })).toEqual({ + messages: [{ role: 'user', content: 'hi', timestamp: 0 }, reminder], + }) + }) + + it('escapes closing reminder tags only in fallback messages on both paths', async () => { + const text = 'literal marker' + const update = history('system', [{ type: 'text', text }]) + const options = { ...base, messages: [update, question, update] } + for (const supported of [false, true]) { + const expected = { + systemPrompt: text, + messages: [ + { role: 'user', content: 'hi', timestamp: 0 }, + supported + ? { role: 'system', content: text, timestamp: 0 } + : { role: 'user', content: '\nliteral <\\/system-reminder> marker\n', timestamp: 0 }, + ], + } + expect(toPiContext(options, undefined, undefined, supported)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, supported)).resolves.toEqual(expected) + } }) it('omits an empty initial prompt on both conversion paths', async () => { const options: GenerateOptions = { ...base, messages: [history('system', []), question] } const expected = { messages: [{ role: 'user', content: 'hi', timestamp: 0 }] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) - it('preserves later system messages for pi-ai to resolve model support', async () => { + it('preserves later system messages when native support is enabled', async () => { const options: GenerateOptions = { ...base, system: 'direct', messages: [leading, question, history('system', [{ type: 'text', text: 'additional rule' }]), leading, history('system', []), @@ -533,8 +571,8 @@ describe('pi-ai system prompt source', () => { { role: 'system', content: 'additional rule', timestamp: 0 }, { role: 'system', content: 'lead rule', timestamp: 0 }, ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('preserves repeated system updates in history order on both paths', async () => { @@ -554,8 +592,8 @@ describe('pi-ai system prompt source', () => { expect.objectContaining({ role: 'toolResult', content: [{ type: 'text', text: 'found' }] }), { role: 'system', content: 'lead rule\nnew rule', timestamp: 0 }, ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('preserves prompt updates before the following user message', async () => { @@ -572,8 +610,8 @@ describe('pi-ai system prompt source', () => { { role: 'user', content: 'next question', timestamp: 0 }, expect.objectContaining({ role: 'assistant', content: [{ type: 'text', text: 'second answer' }] }), ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('skips empty system messages on both paths', async () => { @@ -587,8 +625,8 @@ describe('pi-ai system prompt source', () => { { role: 'user', content: 'hi', timestamp: 0 }, expect.objectContaining({ role: 'assistant' }), ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('keeps a later update distinct from an absent initial prompt', async () => { @@ -597,14 +635,14 @@ describe('pi-ai system prompt source', () => { { role: 'user', content: 'hi', timestamp: 0 }, { role: 'system', content: 'lead rule', timestamp: 0 }, ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('rejects non-text system content on both conversion paths', async () => { const options: GenerateOptions = { ...base, messages: [history('system', [{ type: 'reasoning', text: 'hidden' }])] } - expect(() => toPiContext(options)).toThrow('non-text system messages') - await expect(toPiContext(options, imageContext(attachments))).rejects.toThrow('non-text system messages') + expect(() => toPiContext(options, undefined, undefined, true)).toThrow('non-text system messages') + await expect(toPiContext(options, imageContext(attachments), undefined, true)).rejects.toThrow('non-text system messages') }) it('keeps developer text and tool changes together at their recorded position', async () => { @@ -621,8 +659,8 @@ describe('pi-ai system prompt source', () => { toolsAdded: [{ name: 'search', description: 'later', parameters: { type: 'object' } }], toolsRemoved: [{ name: 'lookup' }] }, { role: 'user', content: 'hi', timestamp: 0 }, ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it('accepts text-only developer updates and ignores empty developer events', async () => { @@ -634,8 +672,8 @@ describe('pi-ai system prompt source', () => { { role: 'user', content: 'hi', timestamp: 0 }, { role: 'system', content: [{ type: 'text', text: 'one' }, { type: 'text', text: 'two' }], timestamp: 0 }, ] } - expect(toPiContext(options)).toEqual(expected) - await expect(toPiContext(options, imageContext(attachments))).resolves.toEqual(expected) + expect(toPiContext(options, undefined, undefined, true)).toEqual(expected) + await expect(toPiContext(options, imageContext(attachments), undefined, true)).resolves.toEqual(expected) }) it.each<{ content: ContentBlock[]; error: string }>([ @@ -644,7 +682,7 @@ describe('pi-ai system prompt source', () => { { content: [{ type: 'image', attachment: ref }], error: 'image in an in-history developer message' }, ])('rejects unsupported developer content on both paths: $error', async ({ content, error }) => { const options: GenerateOptions = { ...base, messages: [question, createDeveloperMessage({ source: { kind: 'test' }, content })] } - expect(() => toPiContext(options)).toThrow(error) + expect(() => toPiContext(options, undefined, undefined, true)).toThrow(error) const read = vi.fn() await expect(toPiContext(options, imageContext(projectionStore(read)))).rejects.toThrow(error) expect(read).not.toHaveBeenCalled() diff --git a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts index 77b554ec3b..467d691603 100644 --- a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts +++ b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts @@ -207,7 +207,7 @@ describe('serialized conversation updates', () => { ]) }) - it('sends concatenated system messages and current tools on unsupported routes', async () => { + it('keeps system reminders in history and current tools on unsupported routes', async () => { const update = addition() const { requests } = await send('openai', 'gpt-4.1', { system: 'direct', @@ -217,7 +217,15 @@ describe('serialized conversation updates', () => { toolHistory: { tools: [baseline], updates: [{ messageId: update.id, additions: [added] }] }, }) expect(requests).toHaveLength(1) - expect(requests[0]).toHaveProperty('input.0', { role: 'system', content: 'direct\n\ninitial\n\ncurrent\n\ncurrent' }) + expect(requests[0]).toHaveProperty('input.0', { role: 'system', content: 'direct\n\ninitial' }) + expect(requests[0]).toHaveProperty('input', [ + { role: 'system', content: 'direct\n\ninitial' }, + expect.objectContaining({ role: 'user' }), + { role: 'user', content: [{ type: 'input_text', text: '\ncurrent\n' }] }, + expect.objectContaining({ role: 'user' }), + expect.objectContaining({ role: 'assistant' }), + { role: 'user', content: [{ type: 'input_text', text: '\ncurrent\n' }] }, + ]) expect(JSON.stringify(requests[0])).not.toContain('Instruction one.') expect(JSON.stringify(requests[0])).not.toContain('additional_tools') }) diff --git a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md index 00cb883a82..f1e6edc33b 100644 --- a/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md +++ b/packages/preset/agent-preset/skills/cordis-composition-reference/references/packages.md @@ -153,7 +153,6 @@ Every package below exports a Cordis plugin that a bundle patch can name in a Lo | Package | Config | Description | |---|---|---| | `@deepseek-ai/dsh-agent` | no | Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness | -| `@deepseek-ai/dsh-agent-controller` | no | Delivers messages from outside an Agent into its inbox through an admission stage | | `@deepseek-ai/dsh-agent-default-model` | yes | Default model selection shared by Agent entry points | | `@deepseek-ai/dsh-agent-loop` | yes | The concrete agent loop plugin for the DeepSeek Harness | | `@deepseek-ai/dsh-agent-tool-presentation` | yes | Agent-plane presentation selector: composes one agent's tools as PTC mode or native tool calls | diff --git a/packages/test-support/client-runtime/README.i18n.yaml b/packages/test-support/client-runtime/README.i18n.yaml index 0f7e7badee..0f544b7144 100644 --- a/packages/test-support/client-runtime/README.i18n.yaml +++ b/packages/test-support/client-runtime/README.i18n.yaml @@ -30,8 +30,8 @@ en: 1442fc215c8128e8 zh: 8439861b08e46ea2 /deepseek-ai-dsh-client-test-runtime/use-this-package/roster-and-startup-behavior: - en: 95c5c2121547bd60 - zh: 4dfb089ba7aac04d + en: 2525f42ba8d139f7 + zh: 772661c7358042c8 /deepseek-ai-dsh-client-test-runtime/use-this-package/when-to-use-it: en: eb2f9bc90acb7a62 zh: cb59a2134bc83106 diff --git a/packages/test-support/client-runtime/README.md b/packages/test-support/client-runtime/README.md index dc22248ff0..162e516a2f 100644 --- a/packages/test-support/client-runtime/README.md +++ b/packages/test-support/client-runtime/README.md @@ -93,7 +93,7 @@ The [generic Remote proxy](../remote-mock/README.md#remote-proxy) supports every ### Roster and startup behavior -`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. +`webApp` is the `web` profile's browser roster, read when the assembly entry is first imported from its bundles (`dsh-base`, then `dsh-web-app`) as the launcher composes them, except that a patch matching nothing throws here where the launcher warns: each bundle's `dsh.bundle.patch` list is parsed with the include plugin's YAML dialect and composed by its `applyEntryPatches`, and every enabled row whose package declares `dsh.client.platform === 'web'` becomes a row carrying that declaration's `inject` and `immediately`; `bundleRoster(bundles, anchor, disabledContext)` accepts an explicit Loader evaluation scope for conditional `disabled` flags; without one, browser expressions are rejected. Both bundle and browser-entry lookup prefer an anchor manifest with the requested package name over dependency directories, including self-referencing bundles. `webApp` supplies the Web profile name, not business Host services. Nothing is copied from the bundles, so a bundle change is visible at the next test run. `webApp.closure(names)` keeps the named rows plus everything they inject, transitively (the rows a spec needs to boot those plugins as the bundle composes them), `webApp.pick(names)` and `webApp.without(names)` cut it down by hand, all three throw on unknown names, and `ClientRoster.of(rows)` builds one inline. `remoteDefaultResponses` holds default responses for exactly the Remote endpoints the roster calls while booting with no sessions, no workspaces, and default settings; a spec layers its own `RemoteTable` on top with `mock.load(table)`, and any call without a rule fails the test at `dispose()` through `mock.assertNoUnmatched()`. `mount` requires a roster that provides `uiRenderer`; `start` fails loud otherwise instead of returning an empty container. `client.connection` is the roster's Connection service (no `Context` augmentation declares it), and `connectTimeoutMs` bounds the readiness wait, whose failure message lists the mock log. `reload(name)` rebuilds one Loader entry through the shared Client Modules helper (registry-first teardown, then `entry.refresh()`); each client retains its own module system and instance-bound Connection replacement, so starts and reloads can overlap without changing a page global. `unload(name)` removes an entry and waits for plugin cleanup; `flush()` settles React inside `act`. jsdom implements neither `EventSource` (client-hmr opens one at apply) nor `ResizeObserver` nor the `document.fonts` event target (layout components observe size and font loading at mount), so `start` reference-counts inert stubs for whichever global is absent and `dispose` removes exactly those — a jsdom gap, not a client identity channel. The `@deepseek-ai/dsh-api-remotes` row is dropped from every roster: its generated Remote clients exist only in built `lib/`, and `remote.` is what the tier replaces anyway. The `@deepseek-ai/dsh-api-remote-access` row, which mounts its own generated contribution, is dropped too, and `start` installs `allowAllAccessPlugin` in its place; a plan `provide` entry for either row is refused. `start` instead provides one contract-free proxy per `remote.` service the roster injects (plus the namespaces the mock has rules for at that point; a namespace first registered later has no proxy); `ctx.remote..(...args)` becomes a call on the endpoint `/` carrying the positional args, a stream when the mock registered a `stream()` script and a unary call otherwise, with the generated client's outcome folding (`gateway/internal` for carrier throws, `gateway/cancelled` on abort). An endpoint without a rule is still dispatched, so the mock logs it and `dispose()` fails the test. ### When to use it diff --git a/packages/test-support/client-runtime/README.zh.md b/packages/test-support/client-runtime/README.zh.md index 8c0a670afd..4c86eae6ec 100644 --- a/packages/test-support/client-runtime/README.zh.md +++ b/packages/test-support/client-runtime/README.zh.md @@ -93,7 +93,7 @@ test('registers into the sidebar', async ({ remote, start }) => { ### Roster 与启动行为 -`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 +`webApp` 是 `web` profile 的浏览器 roster,首次 import 装配入口时从它的 bundle(先 `dsh-base`、再 `dsh-web-app`)按启动器的方式现读,只是匹配不到任何行的补丁在这里抛错、启动器只警告:每个 bundle 的 `dsh.bundle.patch` 列表用 include 插件的 YAML 方言解析、用它的 `applyEntryPatches` 合成,每个未禁用且其包声明 `dsh.client.platform === 'web'` 的行成为一行,带上该声明的 `inject` 与 `immediately`;`bundleRoster(bundles, anchor, disabledContext)` 接受显式 Loader 求值作用域来处理条件 `disabled` 标记;未提供作用域时拒绝浏览器表达式。查找 bundle 和浏览器入口时,优先使用包名匹配的 anchor 清单,再搜索依赖目录;这也适用于注册自身浏览器入口的 bundle。`webApp` 仅提供 Web profile 名称,不提供业务 Host 服务。没有任何东西从 bundle 拷贝出来,bundle 一改下次跑测试就能看见。`webApp.closure(names)` 保留点名的行及其传递注入的全部行(即按 bundle 组合方式起这些插件所需的行),`webApp.pick(names)` 与 `webApp.without(names)` 手工裁剪,三者都对未知名字抛错,`ClientRoster.of(rows)` 内联构造一份。`remoteDefaultResponses` 是 roster 在没有 session、没有 workspace、默认设置下启动时恰好会打的那些 Remote 端点的默认响应;测试用 `mock.load(table)` 在其上叠加自己的 `RemoteTable`,任何没有规则的调用都会在 `dispose()` 时经 `mock.assertNoUnmatched()` 让测试失败。`mount` 要求 roster 提供 `uiRenderer`;否则 `start` 响亮失败而不是返回一个空容器。`client.connection` 是 roster 的 Connection 服务(没有任何 `Context` 增强声明它),`connectTimeoutMs` 限定等就绪的时长,超时消息列出 mock log。`reload(name)` 通过共享的 Client Modules 辅助函数重建一个 Loader entry(先拆 registry,再 `entry.refresh()`);每个客户端都保留自己的模块系统和绑定实例的 Connection 替换,因此启动与重载可以重叠而无需改写页面全局变量。`unload(name)` 移除 entry 并等待插件清理完成,`flush()` 在 `act` 内让 React 落定。jsdom 既没有 `EventSource`(client-hmr 在 apply 时打开一个)也没有 `ResizeObserver` 和 `document.fonts` 事件目标(布局组件挂载时观察尺寸与字体加载),所以 `start` 对缺失的全局惰性桩做引用计数,`dispose` 只移除它装的那些——这是 jsdom 的缺口,不是客户端身份通道。每个 roster 里的 `@deepseek-ai/dsh-api-remotes` 行都会被去掉:它生成的 Remote 客户端只存在于构建后的 `lib/`,而 `remote.` 正是本档要替掉的东西。自行挂载其生成 contribution 的 `@deepseek-ai/dsh-api-remote-access` 行同样会被去掉,`start` 改为安装 `allowAllAccessPlugin`;计划中为这两行之一提供的 `provide` 项会被拒绝。`start` 改为给 roster 注入的每个 `remote.` 服务(加上此刻 mock 登记过规则的命名空间;之后才首次登记的命名空间没有代理)提供一个无契约代理;`ctx.remote..(...args)` 变成对端点 `/` 的调用,携带位置参数,mock 登记了 `stream()` 脚本的走流、否则走一元,并沿用生成客户端的结果折叠(载体抛错折成 `gateway/internal`,中止折成 `gateway/cancelled`)。没有规则的端点照样发出,所以 mock 会记下它、`dispose()` 让测试失败。 ### 何时使用 diff --git a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts index 6b362b187d..62ac9fc816 100644 --- a/packages/test-support/client-runtime/src/assembly/bundle-roster.ts +++ b/packages/test-support/client-runtime/src/assembly/bundle-roster.ts @@ -15,7 +15,7 @@ */ import { existsSync, readFileSync, realpathSync } from 'node:fs' import { createRequire } from 'node:module' -import { dirname, join } from 'node:path' +import { basename, dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { evaluate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' @@ -130,8 +130,14 @@ function readManifest(path: string): PackageManifest { return JSON.parse(readFileSync(path, 'utf8')) as PackageManifest } -/** Resolve package manifests to real paths so linked bundles use their own dependency directories. */ +/** + * Prefer an anchor manifest with the requested package name, then search dependencies by depth and anchor order. + * Return real paths so linked bundles use their own dependency directories. + */ function locateManifest(anchors: readonly string[], name: string): string | undefined { + for (const anchor of anchors) { + if (basename(anchor) === 'package.json' && readManifest(anchor).name === name) return realpathSync(anchor) + } const paths = anchors.map(anchor => createRequire(anchor).resolve.paths(name) ?? []) for (let depth = 0; depth < Math.max(...paths.map(search => search.length)); depth++) { for (const search of paths) { diff --git a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts index d7b018cb36..4228acb755 100644 --- a/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts +++ b/packages/test-support/client-runtime/tests/assembly-bundle-roster.client.spec.ts @@ -74,6 +74,31 @@ describe('bundleRoster on a scratch installation', () => { const scratch = new Scratch() afterAll(() => { rmSync(scratch.root, { recursive: true, force: true }) }) + it('resolves a bundle that registers its own browser entry', () => { + const linked = new Scratch() + onTestFinished(() => { rmSync(linked.root, { recursive: true, force: true }) }) + const bundle = join(linked.root, 'workspace', 'bundle') + mkdirSync(bundle, { recursive: true }) + writeFileSync(join(bundle, 'package.json'), JSON.stringify({ + name: '@t/self', dsh: { bundle: { patch: './cordis.patch.yml' }, client: { platform: 'web' } }, + })) + writeFileSync(join(bundle, 'cordis.patch.yml'), "- insert:\n - name: '@t/self'\n") + mkdirSync(join(linked.root, 'app', 'node_modules', '@t'), { recursive: true }) + symlinkSync(bundle, join(linked.root, 'app', 'node_modules', '@t', 'self'), 'junction') + expect(linked.roster(['@t/self'])).toEqual(['@t/self']) + }) + + it('resolves an anchor bundle and its browser entry before a same-named dependency', () => { + const anchored = new Scratch() + onTestFinished(() => { rmSync(anchored.root, { recursive: true, force: true }) }) + writeFileSync(anchored.anchor, JSON.stringify({ + name: '@t/self', dsh: { bundle: { patch: './cordis.patch.yml' }, client: { platform: 'web' } }, + })) + writeFileSync(join(anchored.root, 'app', 'cordis.patch.yml'), "- insert:\n - name: '@t/self'\n") + anchored.bundle('@t/self', '- insert: []\n') + expect(anchored.roster(['@t/self'])).toEqual(['@t/self']) + }) + it('resolves a linked bundle dependency before an unrelated ancestor package', () => { const linked = new Scratch() onTestFinished(() => { rmSync(linked.root, { recursive: true, force: true }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6eebbe627..6c18f37f32 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1525,9 +1525,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent - '@deepseek-ai/dsh-agent-controller': - specifier: workspace:* - version: link:../../core/agent-controller '@deepseek-ai/dsh-agent-default-model': specifier: workspace:* version: link:../../core/agent-default-model @@ -2274,9 +2271,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../core/agent - '@deepseek-ai/dsh-agent-controller': - specifier: workspace:* - version: link:../../core/agent-controller '@deepseek-ai/dsh-agent-default-model': specifier: workspace:* version: link:../../core/agent-default-model @@ -7307,27 +7301,6 @@ importers: specifier: workspace:* version: link:../../workspace/workspace - packages/core/agent-controller: - devDependencies: - '@deepseek-ai/cordis': - specifier: workspace:~ - version: link:../../../vendor/cordis - '@deepseek-ai/dsh-agent': - specifier: workspace:* - version: link:../agent - '@deepseek-ai/dsh-llm': - specifier: workspace:* - version: link:../../llm/llm - '@deepseek-ai/dsh-scope': - specifier: workspace:* - version: link:../scope - '@deepseek-ai/dsh-session': - specifier: workspace:* - version: link:../session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:* - version: link:../../session/session-projection - packages/core/agent-default-model: dependencies: '@deepseek-ai/dsh-config-editor': @@ -15950,9 +15923,6 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:* version: link:../../packages/core/agent - '@deepseek-ai/dsh-agent-controller': - specifier: workspace:* - version: link:../../packages/core/agent-controller '@deepseek-ai/dsh-agent-default-model': specifier: workspace:* version: link:../../packages/core/agent-default-model diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 2022edec7c..19f2de7e0c 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,7 +14,6 @@ "@deepseek-ai/dsh": "workspace:*", "@deepseek-ai/dsh-acp": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-agent-controller": "workspace:*", "@deepseek-ai/dsh-agent-default-model": "workspace:*", "@deepseek-ai/dsh-agent-instructions": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 9c0ffc9d2d..e30512816e 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -1257,7 +1257,7 @@ describe('Weighted approval workflow', () => { if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event job must define steps') const steps = job.steps.filter(isRecord) const checkout = steps.find(step => step.name === 'Check out trusted approval policy') - const publish = steps.find(step => step.name === 'Publish weighted approval decision') + const publish = steps.find(step => step.name === 'Publish weighted approval status') const recordSteps = recordJob.steps.filter(isRecord) const record = recordSteps.find(step => step.name === 'Record review event') @@ -1272,9 +1272,9 @@ describe('Weighted approval workflow', () => { expect(review.types).toEqual(['submitted', 'edited', 'dismissed']) expect(reviewEvent.permissions).toEqual({}) expect(publisher.permissions).toEqual({ - checks: 'write', contents: 'read', 'pull-requests': 'write', + statuses: 'write', }) expect(publisher.concurrency).toEqual({ group: "weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}", @@ -1302,10 +1302,10 @@ describe('Weighted approval workflow', () => { const revokeIndex = steps.findIndex(step => step.id === 'revoke') expect(revokeIndex).toBeGreaterThan(steps.indexOf(checkout!)) expect(revokeIndex).toBeLessThan(setupIndex) - expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs revoke') + expect(steps[revokeIndex]?.run).toBe('node .github/review-ownership/check-approval.mjs pending') expect(steps.at(-1)).toMatchObject({ - if: "(failure() || cancelled()) && steps.publish.outputs.concluded == ''", - run: 'node .github/review-ownership/check-approval.mjs failure', + if: "failure() && steps.revoke.outputs.active == 'true'", + run: 'node .github/review-ownership/check-approval.mjs error', }) const pythonJob = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'python-sdk') expect(pythonJob.steps).toContainEqual({ @@ -1317,7 +1317,6 @@ describe('Weighted approval workflow', () => { run: 'python3 -m pip install -r .github/review-ownership/requirements.txt', }) expect(publish).toMatchObject({ - id: 'publish', if: "steps.revoke.outputs.active == 'true'", env: { GITHUB_TOKEN: '${{ github.token }}', diff --git a/scripts/doc-budgets.manifest.json b/scripts/doc-budgets.manifest.json index e002578c5a..f301060236 100644 --- a/scripts/doc-budgets.manifest.json +++ b/scripts/doc-budgets.manifest.json @@ -1,7 +1,7 @@ { "AGENTS.md": 2080, "docs/AGENTS.md": 1430, - "docs/architecture.md": 2430, + "docs/architecture.md": 2410, "docs/cordis-primer.md": 600, "docs/testing.md": 1350, "packages/AGENTS.md": 810, diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index f5791d537d..cc22a870be 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -67,7 +67,6 @@ export const SERVICE_PAGE: Record = { hmr: 'boot.md', mcpResources: 'mcp.md', agentLoop: 'core.md', - agentController: 'core.md', agentDefaultModel: 'core.md', agentPresets: 'core.md', agents: 'core.md', @@ -198,6 +197,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the API', layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the API', pluginNavigation: 'client-side bundle navigation — packages/client/ui-plugin-manager/README.md owns the API', + pluginChanges: 'client-side read-only management activity — packages/client/ui-plugin-manager/README.md owns the API', locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the API', modelDirectories: 'client-side interface-typed browser service — packages/client/ui-model-selection/README.md owns the API', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the API', @@ -226,7 +226,6 @@ export const EVENT_SCOPE_PAGE: Record = { 'plugin-manager': 'boot.md', 'plugin-market': 'boot.md', 'agent': 'core.md', - 'agent-controller': 'core.md', 'agent-loop': 'core.md', 'agent-preset': 'core.md', 'api-session': 'session.md', @@ -353,9 +352,6 @@ export const LINK_MAP: Readonly> = { AllowedModelRoute: 'subagent.md', SubagentModelSelectionSettings: 'subagent.md', AgentOptions: 'core.md', - AdmissionDecision: 'core.md', - DeliveryMode: 'core.md', - DeliveryRequest: 'core.md', TurnBoundaryProjection: 'core.md', AgentStatus: 'core.md', AssistantStreamFrame: 'core.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 299eb8b04d..94d7ff7c34 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -641,14 +641,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['agent-loop', 'acp', 'subagent'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, - { - key: 'agentController', - pkg: 'agent-controller', - title: 'Outside-message delivery', - mode: 'core', - consumers: ['api-session-controller'], - note: 'Prepares, admits through the agent-controller/admission stage, and appends messages that senders bring on someone\'s behalf, one at a time per Agent.', - }, { key: 'agentDefaultModel', pkg: 'agent-default-model', diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index 5ae7814525..ea7317a03e 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -97,7 +97,6 @@ "d0d869812e25f9b60a88de7207566804dc7d1f38c74e0c1d67a7b25fee5af3e9": 1 }, "apps/web/tests/shipped-composition.e2e.ts": { - "5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1, "c0a4d4dd9b52881eb8190c56fb8ea5a25a70590b78b1e4a0996defab767699f0": 1 }, "apps/web/tests/smoke-real.e2e.ts": { @@ -1024,9 +1023,6 @@ "3ae979a4364fe22772c89133e79ea216cdb4f3163c7cb02c47eb1a5d4788e661": 3, "ae5776af8c1e9965a4eca94bcbc07b65da8d2e9e24e4570d2141b34d60b4f90f": 1 }, - "packages/experimental/auto-review/tests/auto-review.spec.ts": { - "5bb6193769cfbb9fc09abd705fed714917784de269546fde14ac1171fb00644b": 1 - }, "packages/experimental/browser-use-runtime/src/index.ts": { "04bc8a44533574365dfedf563e2356458bcf95f990a00906938c977c1d683356": 1 }, diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index a7c0978c25..53fd5a6f73 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -2526,21 +2526,6 @@ "doc": "docs/subsystems/session-projection.md", "symbol": "ProjectionKeySelection", "source": "packages/session/session-projection/src/index.ts" - }, - { - "doc": "docs/subsystems/core.md", - "symbol": "DeliveryMode", - "source": "packages/core/agent-controller/src/types.ts" - }, - { - "doc": "docs/subsystems/core.md", - "symbol": "DeliveryRequest", - "source": "packages/core/agent-controller/src/types.ts" - }, - { - "doc": "docs/subsystems/core.md", - "symbol": "AdmissionDecision", - "source": "packages/core/agent-controller/src/types.ts" } ] } diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6ae34f07fa..52c9ab0bea 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -97,7 +97,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/util/plugin-market-index': { kind: 'indirect', reason: 'Pure index validation; the plugin_market tool owns the entries it returns to the model.' }, 'packages/util/time': { kind: 'indirect', reason: 'Pure zone validation; the consumer that records a canonical zone owns the model-visible line derived from it.' }, 'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' }, - 'packages/core/agent-controller': { kind: 'indirect', reason: 'The controller appends sender-prepared messages to the inbox; the Agent loop owns how they enter a model request.' }, 'packages/llm/deepseek-llm-api-extensions': { kind: 'indirect', reason: 'The registry contributes model-hidden provider fields; dsh-llm-deepseek owns their wire placement.' }, 'packages/preset/agent-preset': { kind: 'indirect', reason: 'The declaration selects plugins that own model-visible contributions.' }, 'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 1b6c854614..2ff0367a9e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -374,7 +374,6 @@ "@deepseek-ai/dsh-acp": ["./packages/acp/acp/src"], "@deepseek-ai/dsh-acp-app": ["./packages/bundle/acp-app/src"], "@deepseek-ai/dsh-agent": ["./packages/core/agent/src"], - "@deepseek-ai/dsh-agent-controller": ["./packages/core/agent-controller/src"], "@deepseek-ai/dsh-agent-default-model": ["./packages/core/agent-default-model/src"], "@deepseek-ai/dsh-agent-instructions": ["./packages/context/agent-instructions/src"], "@deepseek-ai/dsh-agent-loop": ["./packages/core/agent-loop/src"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 70d0775bcb..7bb79cebbe 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -8,6 +8,7 @@ "rewriteRelativeImportExtensions": false }, "include": [ + "apps/web/tests/hover-input.e2e.ts", "apps/web/tests/collapse-timing.e2e.ts", "apps/web/tests/notice-wake-tail.e2e.ts", "apps/web/tests/fold-blank-matrix.e2e.ts", @@ -380,7 +381,6 @@ { "path": "./packages/session/session-telemetry-otel" }, { "path": "./packages/core/system-prompt" }, { "path": "./packages/core/agent" }, - { "path": "./packages/core/agent-controller" }, { "path": "./packages/core/agent-default-model" }, { "path": "./packages/interaction/commands" }, { "path": "./packages/goal/goal" },