diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml index 820299cf2e..2f7893a09f 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md -2026-07-16-explicit-turn-cancellation.md: ca56c77a097e3008a50c2aec24040a4f4b6f0ba3 -2026-07-16-explicit-turn-cancellation.zh.md: bf410e5c7284a9c9914edbd14445074e71dd6943 +2026-07-16-explicit-turn-cancellation.md: 2b7cb8cc77184edf1331764d28aefe748c1614a1 +2026-07-16-explicit-turn-cancellation.zh.md: 68089e2c48d239afbff4c10cba5a202b4b6ff262 diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md index ca56c77a09..2b7cb8cc77 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md @@ -18,7 +18,7 @@ An interrupted live turn ends with the coarse durable `{ kind: 'aborted' }` outc AgentLoop privately owns one `TurnCancellation` per prospective turn. It installs the holder before notifying `agent/status = running`, retains its single `AbortController` through inbox claim, `agent/pre-step`, prompt assembly, every step, model and tool execution, and `agent/turn-stopping`, then clears the exact holder immediately before publishing `turn/end`. Terminal event observers and the following durability flush therefore cannot cancel already-completed turn work even though driver status may remain `running` until the flush settles. Every participating method, event, and request value receives that same explicit signal; the next turn receives a fresh signal. -The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer belongs to the next turn. If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. +The driver keeps only a cause-less pre-run marker for queued work cancelled before a turn is claimed. An effective `cancel()` emits the observe-only `agent/cancel-requested` notification with its resolved typed cause before clearing queued and steering work or aborting the holder; notification failures cannot veto the stop, and an idle call emits nothing. Work synchronously queued by a notification observer is included in that clear, while work queued by a later signal abort observer is latched and runs when the aborted activity converges to idle — a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). If a `running` listener synchronously cancels old work and sends a replacement, the driver discards the aborted holder and creates a fresh one for the replacement. Repeated cancellation is first-wins for the active holder, while later calls may still clear newly queued pending work. The explicit event signatures pass a single payload object: agent-scoped events carry `agent` and `signal` in the payload with `next` last, and the remaining seams keep `signal` immediately before a waterfall's final `next`. `PreStepContext` and `RequestFailureContext` are retired, with their fields folded into the `agent/pre-step` and `agent/request-error` payloads ([payload-object events](2026-08-06-agent-event-payload-objects.md)). Pre-step entry, request configuration, request-error recovery, model generation, tool execution, approval, turn stopping, and subagent or workflow requests all receive the current signal. Hook bridges must also supply `RunHookOptions.signal`, so a turn cancellation reaches the bash executor's process-group kill and join boundary. `SystemPrompt.assemble()` carries `signal?: AbortSignal` in `AssembleContext` because that object is an explicit request value that can also represent signal-less assembly outside a turn. Listeners may cooperate with the signal but must not retain it to control another turn. diff --git a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md index bf410e5c72..68089e2c48 100644 --- a/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.zh.md @@ -18,7 +18,7 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作属于下一个轮次。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 seam 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。pre-step 进入决策、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml new file mode 100644 index 0000000000..d31f344c27 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md +2026-08-07-cancel-convergence-wake-latch.md: fe00c78bdfadac0cc6c9c173fd04256f77a22051 +2026-08-07-cancel-convergence-wake-latch.zh.md: f76ad360e2d8b70f76c6baf53e2fde963a80b6c4 diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md new file mode 100644 index 0000000000..fe00c78bdf --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md @@ -0,0 +1,29 @@ +# Agent Note: Latch wake-ups that land in the cancel-convergence window + +Status: implemented + +English | [中文](2026-08-07-cancel-convergence-wake-latch.zh.md) + +## Problem + +`Agent.cancel(cause, { keepInbox: true })` returns immediately after firing the abort signal, but the active driver may not have converged to `idle` yet: LLM stream teardown, tool cancellation, and the `turn/end` append all unwind asynchronously after `abort()` returns. A waking send arriving in that window was placed into `next-turn` while `wakeDriver()` returned early on the still-`running` phase, and the exiting driver never replayed the wake — the message stayed parked until another waking send arrived. The same dropped-wake window existed around aborted `runMaintenance` activities. Several tests enshrined the parked behavior ("waits for another wakeup"); the bug broke both `session.cancel` and the `subagent.interrupt` composition path (issue #1838). The owning cancellation and send contracts are the [explicit turn cancellation](../architecture/2026-07-16-explicit-turn-cancellation.md) and [unified send](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md) decisions; the production `keepInbox` consumer is [web stop preserves queue](2026-07-31-web-stop-preserves-queue.md). + +## Decision + +The `running` phase carries a `wakeRequested` latch, mirroring the existing `maintenance` phase field. `wakeDriver()` latches whenever the current activity cannot deliver the wake — a maintenance task never reads the queue, and an aborted activity converges without restarting — while a live driver needs no latch because it claims queued work itself. The exiting activity replays the latch at its own convergence boundary (`kick`'s `finally` and `runMaintenance`'s `finally`): this placement guarantees `turn/end N` lands before the replayed driver opens `turn/start N+1`, and that `whenIdle()` sees the replayed driver through its `activityDone` loop. The replay sites run only while `inbox.hasPending`, so a latched wake removed from the inbox before convergence does not start an empty driver. A wake sent while the agent is already idle keeps its turn boundary even when its message is cleared before the driver claims — that `idle → running → idle` transition is an observable contract: the goal-session driver's pause/disarm fallback fires on the `idle` transition after a cancelled reservation (CI caught this when the guard was moved into `wakeDriver()` and suppressed the boundary). `cancel()` without `keepInbox` clears the latch together with the inbox. + +The `signal.aborted` discriminator is load-bearing: it separates pre-abort queued work — which `keepInbox` parks for a later wake (acceptance criterion 1) — from post-abort explicit wakes, which must run after convergence. + +## Alternatives considered + +**Have `cancel()` set the phase to `idle` immediately.** Rejected: the driver is still unwinding, so this overlaps two drivers. The replay lives in the old driver's `finally`, which then never runs — 14 of 83 tests failed, several deadlocked. Repairing it requires identity-based phase ownership plus a turn-open quiescence barrier, which is strictly more machinery and is the latch in disguise. + +**Latch unconditionally for every non-idle wake.** Rejected: pre-abort wakes would auto-start after a `keepInbox` cancel, violating acceptance criterion 1; the "parks queued work" test and the error-window steering test both failed. + +**Replay through a chained promise (`activityDone.then(...)`).** Rejected: the replay would run outside the activity's own settlement, so `whenIdle()`'s loop can resolve before the replayed driver starts; fixing that requires replacing `activityDone` at send time and depends on microtask reaction ordering — more fragile than a synchronous flag. + +**Wait for quiescence in the subagent adapter.** Rejected by the issue scope: the cancel/wake state machine owns the fix, not a consumer. + +## Consequences + +The `running` phase gains a `wakeRequested` field; `cancel()` without `keepInbox` clears it alongside the inbox, and a `disposed` cancel never latches, so a wake landing after disposal begins stays parked and `whenIdle()` does not wait on a full model turn over the session being torn down. A wake arriving in the sub-microtask gap between the driver's final `hasPending` check and its exit still parks — no latch fires because the phase is `running` and not aborted; closing that gap requires the unconditional latch and is deliberately out of scope. Between the aborted turn and the replayed driver, status transitions emit a transient `idle → running` pair. A waking send whose message is cleared before any driver claims it still opens an empty completed turn, preserving the observable wake boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md new file mode 100644 index 0000000000..f76ad360e2 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 锁存取消收敛窗口内到达的唤醒请求 + +Status: implemented + +[English](2026-08-07-cancel-convergence-wake-latch.md) | 中文 + +## 问题 + +`Agent.cancel(cause, { keepInbox: true })` 在触发 abort 信号后立即返回,但活动 driver 可能尚未收敛到 `idle`:LLM 流拆除、工具取消与 `turn/end` 落盘都会在 `abort()` 返回后异步展开。在该窗口内到达的唤醒 send 被放入 `next-turn`,而 `wakeDriver()` 对仍处于 `running` 的 phase 直接返回,退出的 driver 也从不重放这次唤醒——消息会一直停放到下一条唤醒 send 到达。被中止的 `runMaintenance` 活动周围也存在同样的唤醒丢失窗口。多个测试固化了停放行为(「等待下一次唤醒」);该缺陷同时破坏了 `session.cancel` 与 `subagent.interrupt` 组合路径(issue #1838)。拥有取消与发送契约的既有决策是[显式轮次取消](../architecture/2026-07-16-explicit-turn-cancellation.md)与[统一发送](../architecture/2026-07-22-unified-send-and-coalesced-user-messages.md);生产环境中的 `keepInbox` 消费方是[Web 停止保留队列](2026-07-31-web-stop-preserves-queue.md)。 + +## 决策 + +`running` phase 携带 `wakeRequested` 锁存,与既有的 `maintenance` phase 字段对称。`wakeDriver()` 在当前活动无法投递唤醒时锁存——maintenance 任务从不读取队列,被中止的活动收敛后不会重启——而存活的 driver 不需要锁存,因为它自己会认领排队的工作。退出中的活动在其自身收敛边界(`kick` 的 `finally` 与 `runMaintenance` 的 `finally`)重放锁存:这一位置保证 `turn/end N` 先于重放 driver 打开 `turn/start N+1` 落盘,并保证 `whenIdle()` 通过其 `activityDone` 循环看到重放 driver。两个重放点仅在 `inbox.hasPending` 时执行,因此收敛前被从 inbox 移除的锁存唤醒不会启动空 driver。而 agent 已处于 idle 时发送的唤醒,即使消息在 driver 认领前被清除,仍会打开自己的 turn 边界——这趟 `idle → running → idle` 转换是可观察契约:goal-session driver 的 pause/disarm 回退依赖取消预订后的 `idle` 转换触发(把守卫放进 `wakeDriver()` 后该边界被抑制,CI 发现了这一点)。不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除锁存。 + +`signal.aborted` 判别项是承重的:它区分「中断前已排队的工作」——`keepInbox` 将其停放以待后续唤醒(验收条件 1)——与「abort 后显式的唤醒」,后者必须在收敛后执行。 + +## 备选方案 + +**让 `cancel()` 立即把 phase 置为 `idle`。** 不予采用:driver 仍在展开收尾,这会重叠两个 driver。重放逻辑位于旧 driver 的 `finally`,而该 `finally` 此后不再执行——83 个测试中有 14 个失败,多个死锁。修复它需要基于身份的 phase 所有权外加 turn 打开时的 quiescence 屏障,机制上严格更重,而且该屏障就是换了个形态的锁存。 + +**对每个非 idle 唤醒无条件锁存。** 不予采用:中断前的唤醒会在 `keepInbox` 取消后自动启动,违反验收条件 1;「停放排队工作」测试与错误窗口的 steering 测试双双失败。 + +**通过链式 promise(`activityDone.then(...)`)重放。** 不予采用:重放会运行在活动自身结算之外,`whenIdle()` 的循环可能在重放 driver 启动前就 resolve;修复它需要在 send 时同步替换 `activityDone`,并依赖微任务反应顺序——比同步 flag 更脆弱。 + +**在 subagent adapter 中等待 quiescence。** 被 issue 范围否决:修复由取消/唤醒状态机拥有,而不是消费方。 + +## 影响 + +`running` phase 新增 `wakeRequested` 字段;不带 `keepInbox` 的 `cancel()` 会连同 inbox 一起清除它,且 `disposed` 取消从不锁存——dispose 开始后到达的唤醒保持停放,`whenIdle()` 不会在拆除中的会话上等待一个完整模型 turn。落在 driver 最后一次 `hasPending` 检查与退出之间微任务间隙的唤醒仍会停放——没有锁存触发,因为 phase 是 `running` 且未 abort;关闭该间隙需要无条件锁存,刻意留作范围外。在被中止的 turn 与重放 driver 之间,状态转换会发出一次瞬态 `idle → running` 对。唤醒 send 的消息在任何 driver 认领前被清除时,仍会打开一个空的 completed turn,保留可观察的唤醒边界。 diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml new file mode 100644 index 0000000000..8153420c3d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md +2026-08-07-web-inline-file-mentions.md: 581efb5a9eb497e030d19118d52d003b37118108 +2026-08-07-web-inline-file-mentions.zh.md: 25c8ca6e106cd877a4d46a7b1338728e96771fc1 diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md new file mode 100644 index 0000000000..581efb5a9e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md @@ -0,0 +1,28 @@ +# Agent Note: inline-code file mentions open the file they name + +Status: implemented + +English | [中文](2026-08-07-web-inline-file-mentions.zh.md) + +> Scope: linking inline-code tokens in the closing message's prose to the files the turn produced. Not in scope: recognizing paths in plain prose, linking files the turn did not produce, and mentions in streaming or mid-turn messages. + +## Problem + +The produced-files row lists a turn's output, but the closing message usually also *names* the file in prose — as inline code, like `` `deepseek-homepage.html` `` — and that mention was inert text. The reader's eye lands on the sentence first; the affordance sat one row below it. + +## Decision + +**A prose mention links only when it matches a produced file.** The [produced-files decision](2026-07-31-web-workspace-file-links.md) rejected linkifying the closing message because rendering must not depend on the model spelling a path recognizably; that holds. The row remains the authoritative, prose-independent account. This feature adds a second consumer of the same `locations` vocabulary: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path. A basename two paths share stays inert rather than guessing, and a token naming nothing the turn wrote stays inert — a mention link can never 404. + +**The renderer owns no vocabulary, and the provider is the deliverables plugin.** `MarkdownText` takes an optional `MarkdownFileMentions` resolver and consults it for inline-code tokens — after URL promotion, which wins, and never inside an anchor, where a button cannot nest. What names a file is decided behind the optional `chatFileMentions` service ui-conversation reaches via `ctx.get`: ui-deliverables provides it beside its turn-tail chain entry, so one cordis.yml line composes the row and the prose links in or out together, and ui-primitives gains no session concepts. Mentions apply to settled renders only — the streaming cache must not bake in handlers that could go stale, and the vocabulary is not final until the turn closes. The consumer memoizes the resolver on the closing seq rather than the growing transcript, so a settled message's cached parse survives stream appends. + +## Alternatives considered + +- **Path-shaped regex over all prose** — links `package.json` mentioned abstractly and examples that were never written; every false positive is a click that opens nothing or the wrong file. The vocabulary approach cannot produce a dead link. +- **Linking suffix matches (`out/index.html` mentioned as `index.html` in a subdirectory listing)** — deferred; exact path and unique basename cover the observed closing-message shapes, and a wider matcher can loosen later without breaking the seam. +- **Resolving in ui-primitives against a passed path list** — puts matching policy in the generic renderer, where other consumers would inherit it unasked. The resolver seam keeps policy with the owner. +- **Threading the vocabulary through the turn-tail chain** — the chain is a render dispatch below the message; mentions decorate markdown inside it, which only data reaching MarkdownText can do. The optional service is that data path, and its absence is the off state. + +## Consequences + +The mention and the row are two affordances for one fact (full path as `title` on both); the mention itself wears the markdown sheet's anchor language — link-blue at rest, hover underline — because an at-rest underline collides with monospace descenders inside the code chip. `apps/web/tests/produced-file-mentions.e2e.ts` pins the assembled behavior with a built write-turn seed: unique basename links, ambiguous and unknown tokens stay inert; it does not click, for the produced-files restraint (the opener launches a real application). Mentions in mid-turn narration stay inert even for files the turn later produces, because the vocabulary attaches to the closing message only. The window-prepend edge — a window that starts mid-turn later gaining earlier same-turn writes — leaves a mention unlinked until remount, never wrongly linked. diff --git a/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md new file mode 100644 index 0000000000..25c8ca6e10 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.zh.md @@ -0,0 +1,28 @@ +# Agent Note:行内代码文件提及可打开其命名的文件 + +Status: implemented + +[English](2026-08-07-web-inline-file-mentions.md) | 中文 + +> 范围:把收尾消息正文中的行内代码 token 链接到本轮产出的文件。不在范围内:识别普通正文中的路径、链接本轮未产出的文件,以及流式或轮次中途消息里的提及。 + +## 问题 + +产物行列出了一轮的输出,但收尾消息通常也会在正文里*点名*文件——以行内代码形式,如 `` `deepseek-homepage.html` ``——而这个提及是死文本。读者的视线先落在句子上;可点击的交互却在下面一行。 + +## 决定 + +**正文提及只在与产出文件对得上时才成为链接。**[产物行的决定](2026-07-31-web-workspace-file-links.md)否决过"把收尾消息链接化",理由是渲染不能依赖模型把路径写得可识别;这一点不变。产物行仍是权威的、不依赖正文的记录。本特性只是给同一份 `locations` 词表增加第二个消费者:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析。两条路径共享的 basename 保持死文本而不猜测,命名了本轮没写过的文件的 token 同样保持死文本——提及链接永远不会 404。 + +**渲染器不持有词表,提供方是 deliverables 插件。**`MarkdownText` 接受可选的 `MarkdownFileMentions` 解析器,对行内代码 token 询问它——URL 提升优先于解析器,且绝不在锚点内部(按钮不能嵌套在链接里)。什么算文件名的决定藏在 ui-conversation 经 `ctx.get` 触达的可选 `chatFileMentions` service 背后:ui-deliverables 在其 turn-tail chain 注册项旁提供该 service,因此 cordis.yml 中的一行同时把产物行和正文链接组合进来或去掉,ui-primitives 不引入任何会话概念。提及只作用于已定稿的渲染——流式缓存不能烘进可能过期的 handler,而且词表在轮次收尾前并不最终。消费方按收尾 seq 而非不断增长的 transcript 记忆化解析器,因此已定稿消息的缓存解析在流式追加中得以保留。 + +## 考虑过的替代方案 + +- **对全部正文跑路径形状的正则**——会把随口提到的 `package.json` 和从未写过的示例都链接上;每个误报都是一次打开空无或错误文件的点击。词表方案不可能产生死链。 +- **链接后缀匹配(子目录列表里把 `out/index.html` 写作 `index.html`)**——暂缓;精确路径加唯一 basename 已覆盖观察到的收尾消息形态,之后放宽匹配器不会破坏这道 seam。 +- **在 ui-primitives 里对传入的路径列表做解析**——把匹配策略放进通用渲染器,其他消费方会被动继承。解析器 seam 让策略留在持有者手里。 +- **经由 turn-tail chain 传递词表**——chain 是消息下方的渲染派发;提及要装饰的是消息内部的 markdown,只有抵达 MarkdownText 的数据才做得到。可选 service 就是那条数据通路,它的缺席即关闭态。 + +## 后果 + +提及与产物行是同一事实的两个交互面(两者都以完整路径作 `title`);提及本身采用 markdown 样式表的锚点语言——静止为链接蓝、悬停出下划线——因为静止下划线在 code 胶囊里会压住等宽字的下伸部。`apps/web/tests/produced-file-mentions.e2e.ts` 用构造的写入轮 seed 钉住组装后的行为:唯一 basename 成链,歧义与未知 token 保持死文本;它不驱动点击,沿用产物行的克制(opener 会启动真实应用)。轮次中途叙述里的提及即使命名了本轮后来产出的文件也保持死文本,因为词表只挂在收尾消息上。窗口前插的边界——从轮次中途开始的窗口后来补入了同轮更早的写入——只会让提及在重挂载前暂不成链,绝不会错链。 diff --git a/apps/web/tests/produced-file-mentions.e2e.ts b/apps/web/tests/produced-file-mentions.e2e.ts new file mode 100644 index 0000000000..badd9b845e --- /dev/null +++ b/apps/web/tests/produced-file-mentions.e2e.ts @@ -0,0 +1,162 @@ +// Web e2e scenario: inline-code file mentions in the closing prose. Cold-seeds +// a built write turn (zero model calls) whose closing message names the written +// file three ways: by unique basename (links), ambiguously (stays inert), and +// as a file the turn never touched (stays inert). Package tests cover the +// resolver in isolation; only the assembled application shows a real write's +// locations reaching the prose as an opener. The click itself is not driven +// here: it hands the path to the Host's opener, which would launch a real +// application on the machine running the suite (the produced-files restraint). +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { CallId, createAssistantMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-title' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const MODE = webSnapshotMode() +const SEED_ID = 'produced-file-mentions-web-e2e' +const DONE = 'FILE_MENTION_DONE' + +/** One-part text content for a built message. */ +function text(value: string): { type: 'text'; text: string }[] { + return [{ type: 'text', text: value }] +} + +/** The files the built turn writes; `notes.md` is named in prose but never written. */ +const WRITES = ['site/report.html', 'a/style.css', 'b/style.css'] + +/** Build a settled write turn whose closing prose mentions files in inline code. */ +function mentionFixture(): string { + const session = Session.create(SessionId('produced-file-mentions-source')) + const eventTimeOrigin = new Date().setHours(12, 0, 0, 0) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Write the report page and both stylesheets.' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { + title: 'Produced file mentions', + messageSeqs: [user.seq], + source: { kind: 'fallback' }, + }) + session.append('step/start', { turn: 1, step: 1 }) + const calls = WRITES.map((path, index) => ({ + path, + callId: CallId(`file-mention-${String(index)}`), + args: JSON.stringify({ file_path: path, content: `content of ${path}\n` }), + })) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: calls.map(call => ({ + type: 'tool-call' as const, + id: call.callId, + name: 'write', + arguments: call.args, + })), + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + for (const call of calls) { + const source = session.append('tool/call', { + turn: 1, + step: 1, + callId: call.callId, + name: 'write', + arguments: call.args, + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: call.callId, + content: text(`Created ${call.path}`), + isError: false, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [source.seq] }) + } + session.append('step/start', { turn: 1, step: 2 }) + session.append('assistant/message', { + turn: 1, + step: 2, + message: createAssistantMessage({ + content: [{ + type: 'text', + text: [ + 'Wrote `report.html` plus two `style.css` copies; `notes.md` untouched.', + '', + DONE, + ].join('\n'), + }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 2 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + return [ + JSON.stringify({ + type: 'session', + version: SESSION_FORMAT_VERSION, + id: '{{sessionId}}', + createdAt: 0, + cwd: '{{cwd}}', + }), + ...session.events.map(event => JSON.stringify({ + ...event, + time: eventTimeOrigin + event.seq * 1_000, + })), + '', + ].join('\n') +} + +describe('web e2e: inline-code mentions of produced files', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, mentionFixture(), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('links the unique mention and leaves ambiguous and unknown code inert', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-file-mentions')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText(DONE, { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // Exactly one prose mention links: `report.html` resolves to the written + // path; the shared `style.css` basename and unwritten `notes.md` stay code. + const mentions = page.locator('[class*="markdown"] code button') + await expect.poll(() => mentions.count(), { timeout: 10_000 }).toBe(1) + expect(await mentions.first().innerText()).toBe('report.html') + expect(await mentions.first().getAttribute('aria-label')).toBe('Open site/report.html') + expect(await mentions.first().getAttribute('title')).toBe('site/report.html') + // The turn still ends with its produced-files row (all three writes). + expect(await page.getByText('Produced', { exact: true }).count()).toBe(1) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 354781e934..7fb84f1507 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -64,6 +64,7 @@ "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", + "tests/produced-file-mentions.e2e.ts", "tests/goal-bar.e2e.ts", "tests/subagent-conversation.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index ca91a80b7e..de15631197 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 3080d21a24310cc8851d64719d862ec719150ab6 -architecture.zh.md: 6e2bf0c155c68e84580aecc6b7783eeffa25c640 +architecture.md: ea78faa62773a6b8ac98e5baab6e181ad6a3b7f0 +architecture.zh.md: 5a46dfaf84276de5f4dc0352a529bdd4e5d8c267 diff --git a/docs/architecture.md b/docs/architecture.md index 3080d21a24..ea78faa627 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -121,7 +121,7 @@ Pruning precedes summaries; overflow retries require durable progress. `agent/re Adapter selection, dispatch, and iteration failures become terminal error or aborted `finish` chunks. `agent/request-error` receives request coordinates, normalized `LlmFailure`, available retry policy, and signal; middleware and consumer errors remain outside recovery. Failed chunks commit neither messages nor tool calls. -Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). +Other failures use `agent/error`; cancellation and disposal beat recovery. Before request-header commit, the turn signal cancels capability preparation; undispatched tools get synthetic `tool/call`/`ABORTED_BEFORE_DISPATCH` pairs. Effective `cancel(cause)` reports its cause before clearing and aborting; idle calls emit nothing. Waking input that lands after the abort fires but before convergence runs at the driver's convergence boundary, while a `disposed` cancel leaves it parked ([cancel-convergence wake latch](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). Durability distinguishes `aborted` cancellation from `disposed` teardown, which awaits quiescence ([decision](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)). Turn and step events are turn-enclosed; the loop appends `user/message` events only from entered batches inside a turn. A turn opens before the initial claim and pre-step, so rejection, empty input, cancellation, or failure closes a durable turn without any step events. Standalone `compact/* { turn: null }` events consume no turn, and their lock-time markers may interleave with inbox splices. Reload synthesizes interrupted turn ends; `session/end-seed` distinguishes stale compaction orphans from live locks. After close, only `agent/error` reports failures. Each turn has one [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap). diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 6e2bf0c155..5a46dfaf84 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -121,7 +121,7 @@ idle inject: 适配器选择、分发与迭代失败会成为 error 或 aborted 类型的终止 `finish` 分片。`agent/request-error` 接收请求坐标、标准化 `LlmFailure`、可用的重试策略和信号;middleware 与消费方错误仍在恢复之外。失败分片既不提交消息,也不提交工具调用。 -其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 +其他故障使用 `agent/error`;取消和资源释放优先于恢复。在提交请求头之前,轮次信号会取消功能准备;尚未分派的工具会得到合成的 `tool/call`/`ABORTED_BEFORE_DISPATCH` 对。实际生效的 `cancel(cause)` 会在清空队列和中止前报告原因;空闲调用不发事件。abort 触发后、收敛前到达的唤醒输入会在 driver 的收敛边界执行,而 `disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。持久化层以 `aborted` 区分取消,以 `disposed` 区分会等待完全停稳的拆卸([决策](../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md))。 轮次和步骤事件均位于轮次边界内;loop 只会在轮次内从进入步骤的批次追加 `user/message`。轮次会在首次领取与 pre-step 之前打开,因此拒绝、空输入、取消或失败会关闭一个不包含任何步骤事件的持久轮次。独立的 `compact/* { turn: null }` 事件不占用轮次,其锁定时刻标记可以与 inbox splice 交错。重新加载会为中断的轮次合成结束事件;`session/end-seed` 区分陈旧的压缩遗留项与活跃锁。关闭后仅由 `agent/error` 报告故障。每个轮次有一个 [TurnEndReason](core-data-structures/session.md#why-a-turn-ended-turnendreasonmap)。 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 92c2fade12..ae16d10f07 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -33,7 +33,7 @@ A fully configured agent and live session were published. Setup is composition-o Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:154`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:158`](../../packages/core/agent/src/types.ts) ### `agent/disposed` — emit @@ -53,7 +53,7 @@ An agent left the registry; AgentLoop emits this after driver quiescence and sco Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:163`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:167`](../../packages/core/agent/src/types.ts) ### `agent/error` — emit @@ -75,7 +75,7 @@ A step or turn errored. The machine reports a failure here even when the error h Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:285`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:289`](../../packages/core/agent/src/types.ts) ### `agent/inbox/claimed` — emit @@ -97,7 +97,7 @@ One message left the inbox inside its open turn. If the proposed step is rejecte Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:192`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:196`](../../packages/core/agent/src/types.ts) ### `agent/inbox/discarded` — emit @@ -116,7 +116,7 @@ One message was discarded from the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:200`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:204`](../../packages/core/agent/src/types.ts) ### `agent/inbox/inserted` — emit @@ -135,7 +135,7 @@ One message entered the live inbox. Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:181`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:185`](../../packages/core/agent/src/types.ts) ### `agent/pre-step` — waterfall @@ -158,7 +158,7 @@ Reject a proposed step or replace the messages that enter it. Calling `next()` p Types: [Agent](../core-data-structures/core.md) · [PreStepDecision](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [UserMessage](../core-data-structures/session.md) -Source: [`packages/core/agent/src/types.ts:226`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:230`](../../packages/core/agent/src/types.ts) ### `agent/request` — waterfall @@ -182,7 +182,7 @@ Replace the frozen call configuration. `await next()` yields the config the mach Types: [Agent](../core-data-structures/core.md) · [LlmCallConfig](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:239`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:243`](../../packages/core/agent/src/types.ts) ### `agent/request-error` — waterfall @@ -209,7 +209,7 @@ Handle one failed model-request attempt before the loop retries or closes its st Types: [Agent](../core-data-structures/core.md) · [LlmFailure](../core-data-structures/llm-streaming.md) · [RequestErrorAction](../core-data-structures/core.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:255`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:259`](../../packages/core/agent/src/types.ts) ### `agent/session-start` — emit @@ -231,7 +231,7 @@ The session lifecycle began, once before the first turn. Use `agent.inject()` to Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) · [SessionStartSource](../core-data-structures/core.md) -Source: [`packages/core/agent/src/types.ts:212`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:216`](../../packages/core/agent/src/types.ts) ### `agent/status` — emit @@ -252,7 +252,7 @@ Agent status changed (`idle` ⇄ `running`). A waking delivery enters `running` Types: [Agent](../core-data-structures/core.md) · [AgentStatus](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:173`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:177`](../../packages/core/agent/src/types.ts) ### `agent/turn-stopping` — serial @@ -281,7 +281,7 @@ The turn is about to close: the model owes no response (no live tool calls, no f Types: [Agent](../core-data-structures/core.md) · [Scoped](../core-data-structures/scope.md) -Source: [`packages/core/agent/src/types.ts:273`](../../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:277`](../../packages/core/agent/src/types.ts) ## `agent-loop/*` diff --git a/docs/core-data-structures/core.i18n.yaml b/docs/core-data-structures/core.i18n.yaml index e79d5dac2f..ee870f9334 100644 --- a/docs/core-data-structures/core.i18n.yaml +++ b/docs/core-data-structures/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/core-data-structures/core.md -core.md: dbd584f10b3daf873bc14210472efb6cd315717e -core.zh.md: 1fe1616a0c96abb4e8b91417cc4eae292416e42a +core.md: 8f413a7a064ad6f63e0caec31354869e51139020 +core.zh.md: d0f02f0cfc2cd30fc67aacf5b17d1daf324295c5 diff --git a/docs/core-data-structures/core.md b/docs/core-data-structures/core.md index dbd584f10b..8f413a7a06 100644 --- a/docs/core-data-structures/core.md +++ b/docs/core-data-structures/core.md @@ -685,7 +685,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/core-data-structures/core.zh.md b/docs/core-data-structures/core.zh.md index 1fe1616a0c..d0f02f0cfc 100644 --- a/docs/core-data-structures/core.zh.md +++ b/docs/core-data-structures/core.zh.md @@ -693,7 +693,11 @@ interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 5c60be95e1..940d89eae0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,18 +8,18 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:154`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:163`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:285`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | -| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:192`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:200`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | -| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:181`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:226`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | -| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:239`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | -| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:255`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | -| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:212`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | -| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:173`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | -| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:273`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/created` | `emit` | [`packages/core/agent/src/types.ts:158`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:167`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/error` | `emit` | [`packages/core/agent/src/types.ts:289`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/telemetry/session-telemetry) | +| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/types.ts:196`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/types.ts:204`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | +| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/types.ts:185`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/types.ts:230`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`workspace-context`](../packages/context/workspace-context) | +| `agent/request` | `waterfall` | [`packages/core/agent/src/types.ts:243`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent) | +| `agent/request-error` | `waterfall` | [`packages/core/agent/src/types.ts:259`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compact-basic`](../packages/compact/compact-basic), [`llm-retry`](../packages/llm/llm-retry) | +| `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:216`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | +| `agent/status` | `emit` | [`packages/core/agent/src/types.ts:177`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `apiproxy`, [`compact-basic`](../packages/compact/compact-basic), [`goal-session`](../packages/goal/goal-session), [`headless`](../packages/bundle/headless), [`jsonrpc`](../packages/ui/jsonrpc) | +| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:277`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:172`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy` | | `credentials/updated` | `emit` | [`packages/credentials/credentials/src/index.ts:67`](../packages/credentials/credentials/src/index.ts) | [`credentials`](../packages/credentials/credentials) (`events.dispatch`) | `apiproxy`, [`credentials`](../packages/credentials/credentials) | diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index 533ebd3a97..3845c2cb1a 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -100,7 +100,7 @@ Sources: [`packages/core/session/src/types.ts:308`](../packages/core/session/src } ``` -Source: [`packages/core/agent/src/types.ts:296`](../packages/core/agent/src/types.ts) +Source: [`packages/core/agent/src/types.ts:300`](../packages/core/agent/src/types.ts) ### `approval/*` diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 24e7e70f27..35cc21ba04 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 837edaa097d47ebfb72d027616a18fdfeed8a488 -README.zh.md: 419799666dc0689f8fe754d4c9de6d5dcf7fb09c +README.md: 3e3a6b1a09cbed77700fb656882178efd3744a80 +README.zh.md: f3f25426156b3778859ec8f64e8d947362b967ab diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 837edaa097..3e3a6b1a09 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -40,7 +40,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the shared face for slot declarations, composed props, and cross-domain types; `skeleton/`, `chat/`, `input/`, `queue/`, and `settings/` keep their implementations internal, while `apply.ts` is their assembly point. The `/client` export surface contains only loader entries, service classes, and contract types; components and store factories reach the page through slot registrations. -A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. The closing prose participates through the same off switch: the chat view asks the optional `chatFileMentions` service (ctx.get; provided by the same plugin) for a closing message's inline-code vocabulary and threads the result into MarkdownText's `fileMentions` seam — an absent service leaves the prose inert. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 419799666d..f3f2542615 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -40,7 +40,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是 slot 声明、组合 props 与跨领域类型的共享表层;`skeleton/`、`chat/`、`input/`、`queue/` 和 `settings/` 保持内部实现,`apply.ts` 是它们的组装点。`/client` 导出表层只包含 loader entry、service class 和 contract 类型;组件与 store factory 经 slot 注册抵达页面。 -完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。收尾正文经由同一个开关参与其中:chat 视图向可选的 `chatFileMentions` service(ctx.get;由同一插件提供)索取收尾消息的行内代码词表,并把结果接进 MarkdownText 的 `fileMentions` seam——service 缺席时正文保持死文本。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 20056757e6..2d42aef367 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -317,6 +317,7 @@ export function apply(ctx: Context): void { actions.select(target) layout.openDetails() }, + fileMentions: owner => ctx.get('chatFileMentions')?.forClosing(owner), openFile: (path) => { const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd void workspaces.openPath(resolveWorkspacePath(cwd, path)).catch(() => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index bbd4caa2c3..d8883be4cd 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -13,7 +13,8 @@ import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' +import type { ChatViewSlotProps, ChatViewInjected, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ReasoningRow } from './ReasoningRow.tsx' @@ -41,6 +42,8 @@ export interface AssistantMarkdownProps { onFork?: ((seq: number) => void) | undefined /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ turnTail?: (Pick, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined + /** Prose file-mention factory (the injected face); omitted wherever `turnTail` is. */ + fileMentions?: ChatViewInjected['fileMentions'] | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -58,11 +61,25 @@ function copyText(blocks: readonly AssistantBlock[]): string { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, + fileMentions, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. const codeLabels = useMemo(() => ({ copyLabel: t('copy'), copiedLabel: t('copied') }), [t]) + // Mention vocabulary for the closing prose. Keyed on the anchor seq, not the + // growing transcript: a settled turn's produced files are final, and a + // fresh identity per append would discard MarkdownText's cached parse for + // every settled closing message on every stream chunk. The window-prepend + // edge (a mid-turn window start later gaining earlier same-turn writes) + // leaves a mention unlinked until remount — never a wrong link. + const owner = turnTail?.owner + const mentions: MarkdownFileMentions | undefined = useMemo( + () => (owner === undefined ? undefined : fileMentions?.(owner)), + // Deliberately not `owner`: its identity changes per append while the + // seq-addressed vocabulary it yields does not. + [fileMentions, owner?.seq], + ) const last = blocks.length - 1 // Tool-call heads render as tool rows in the chat view's grouping pass, so // a node that is only those heads (or empty) would paint an empty root @@ -79,7 +96,13 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ {blocks.map((block, i) => { switch (block.kind) { case 'text': return ( - + ) case 'reasoning': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index d021e2f388..3636a2a971 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -263,7 +263,8 @@ function StreamingTail({ useSession, t }: { * ordered root Tool call crosses the declared whole-Tool render seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + fileMentions, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -535,6 +536,7 @@ export function ChatView({ turnTail={actionSeqs.has(node.seq) ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} + fileMentions={actionSeqs.has(node.seq) ? fileMentions : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 47093ee77b..a57bcbd5a7 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -4,6 +4,7 @@ import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { CommandNode, CompactionSummaryNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerBlock } from '../input/blocks.ts' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' @@ -160,6 +161,30 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Optional prose file-mention provider, consumed via `ctx.get('chatFileMentions')` + * (optional-service convention): the chat view asks it for a closing message's + * inline-code vocabulary and threads the result into MarkdownText. Absent + * service — the providing plugin composed out of cordis.yml — turns the + * surface off; the prose renders inert code. + */ +export interface ChatFileMentions { + /** + * Mention vocabulary for the closing message the owner currency names. + * @param owner - Turn-tail owner currency (nodes, closing seq, opener). + * @returns The resolver MarkdownText consumes, or undefined when the turn + * produced nothing worth linking. + */ + forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined +} + +declare module 'cordis' { + interface Context { + /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ + chatFileMentions: ChatFileMentions + } +} + /** * Owner currency of the chat view's turn-tail hole: the finalized snapshot * and the closing assistant's anchor. Registrants derive their own facts @@ -519,6 +544,13 @@ export interface ChatViewInjected { } /** Fork through the completed turn ending at the eligible message `seq`, then open the child. */ forkAt: (seq: number) => void + /** + * Prose file-mention vocabulary for one closing message, from the optional + * {@link ChatFileMentions} service (resolved lazily per call, so composing + * the provider in or out takes effect live). Undefined when the service is + * absent or the turn produced nothing worth linking. + */ + fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } /** Full chat-view component props: runtime & its Tool/command/tail render shares & store & injected & locale seat. */ diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index b91628b825..a19cd77753 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -12,6 +12,7 @@ export type { } from './contract/views.ts' export type { ConversationKey } from './locales.ts' export type { + ChatFileMentions, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 38d643292e..9bb043df15 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -180,6 +180,8 @@ function makeHarness(init?: Partial) { inspectCall, chatScroll, forkAt, + // Absent-service default; mention tests override with a real resolver. + fileMentions: () => undefined, // Mirrors the real lookup chain (conversation namespace, then common). t, } @@ -350,6 +352,45 @@ describe('chat-flow derivation', () => { expect([...assistantActionsSeqs(nodes, new Map([[1, 5]]))]).toEqual([5]) }) + it('threads the injected file-mention vocabulary into the closing prose only', () => { + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const h = makeHarness({ + nodes: [ + user(1, 'build it'), + assistant(2, 'writing `report.html` now', 1), + wrote(3, 'w', 'site/report.html'), + assistant(4, 'Wrote `report.html`; `notes.md` untouched.', 1), + ], + turnEnds: new Map([[1, 4]]), + }) + // Stub provider mirroring the real service: only produced files resolve. + h.props.fileMentions = owner => ({ + resolve: (value) => { + if (value !== 'report.html') return undefined + return { + open: () => { h.openFile(`for-seq-${String(owner.seq)}/site/report.html`) }, + label: '打开 site/report.html', + title: 'site/report.html', + } + }, + }) + const view = render() + // Exactly one live mention: the closing message links, the mid-turn + // narration stays inert code, and the unknown file resolves to nothing. + const mentions = view.container.querySelectorAll('code button') + expect(mentions).toHaveLength(1) + const mention = view.getByRole('button', { name: '打开 site/report.html' }) + expect(mention.getAttribute('title')).toBe('site/report.html') + fireEvent.click(mention) + // The vocabulary was built from the closing message's own owner currency. + expect(h.openFile).toHaveBeenCalledWith('for-seq-4/site/report.html') + }) + it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index ee4c23c18c..7c2cb356ec 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 -README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b +README.md: 189dedd88fed6914012204118ccdf9bdd0cd3bb2 +README.zh.md: bfcec3c54602533028942ed167b9526eaf3ca959 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index b8b0ea2ef1..189dedd88f 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -8,6 +8,8 @@ Produced-files feature owner: registers the deliverables row a finished turn end `ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). +The closing prose carries the same vocabulary. This plugin provides the `chatFileMentions` service the chat view consults per closing message: `producedFileMentions` resolves an inline-code token by exact path, or by being exactly the basename of exactly one produced path — a basename two paths share stays inert rather than guessing, so a mention link can never open the wrong file or 404. A resolved mention keeps its code chip and takes the markdown sheet's link language — link-blue at rest, underlined on hover, exactly like URL-promoted inline code — with the full path as its `title`; mentions never render inside anchors or streaming text. Decision record: the [inline file mentions Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md). + ## Model Experience None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. @@ -18,4 +20,4 @@ None; this package neither assembles nor sends provider requests. ## Known Limitations and Deferred Work -- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. +- **Mention matching is exact path or unique basename only.** A suffix mention (`out/index.html` written as `index.html` resolves; `deep/out/index.html` written as `out/index.html` does not) stays inert; widening the matcher is deferred until a real closing-message shape needs it. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index a16535b8a8..bfcec3c546 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -8,6 +8,8 @@ `ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 +收尾正文承载同一份词表。本插件提供 chat 视图按收尾消息查询的 `chatFileMentions` service:`producedFileMentions` 按精确路径解析行内代码 token,或当 token 恰好是且仅是一条产出路径的 basename 时解析——两条路径共享的 basename 保持死文本而不猜测,因此提及链接永远不会打开错误的文件或 404。解析成功的提及保留 code 胶囊并采用 markdown 样式表的链接语言——静止为链接蓝、悬停出下划线,与 URL 提升的行内代码完全一致——完整路径作为其 `title`;提及绝不会渲染在锚点内部或流式文本里。决策记录:[行内文件提及 Agent Note](../../../.agents/notes/implemented/feature/2026-08-07-web-inline-file-mentions.md)。 + ## 模型体验 无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 @@ -18,4 +20,4 @@ ## 已知限制与暂缓事项 -- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 +- **提及匹配只认精确路径或唯一 basename。**后缀式提及(`out/index.html` 写作 `index.html` 可解析;`deep/out/index.html` 写作 `out/index.html` 则不行)保持死文本;放宽匹配器等真实的收尾消息形态需要时再做。 diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index ab85869de2..0baff2872a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -6,18 +6,13 @@ import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' /** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ const SHOWN = 6 -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - /** Matched paths plus the opener and locale seats needed to present them. */ export type ProducedFilesProps = Pick & { matched: readonly string[] diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 6dc7bc4b84..81b2f61b79 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -1,16 +1,18 @@ /** * Deliverables plugin, browser half: registers the produced-files row into - * the chat view's turn-tail hole. All policy lives here — the derivation - * from the mutation tools' `locations`, the chip cap, and the copy — so - * composing this plugin out of cordis.yml removes the surface entirely; the - * owning view renders an empty hole at zero cost. + * the chat view's turn-tail chain, and provides the `chatFileMentions` + * service that links inline-code mentions of produced files in the closing + * prose. All policy lives here — the derivation from the mutation tools' + * `locations`, the mention matching, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes both surfaces entirely; + * the owning view renders an empty chain and inert prose at zero cost. */ import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' -import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' -import { selectProducedFiles } from './turn-deliverables.ts' +import { producedFileMentions, selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -39,4 +41,17 @@ export function apply(ctx: ClientContext): void { locale: NS, }, ProducedFiles), ) + // The prose side of the same vocabulary: the chat view reaches this face + // via ctx.get, so its absence — this plugin composed out — is the off state. + const t = ctx.locale.bind(NS) + const mentions: ChatFileMentions = { + forClosing(owner) { + // Same claim test the turn-tail chain entry runs: no produced files, + // no vocabulary — the two surfaces agree by construction. + const paths = selectProducedFiles(owner) + if (paths === null) return undefined + return producedFileMentions(paths, owner.openFile, path => t('produced.open', { name: path })) + }, + } + ctx.provide('chatFileMentions', mentions) } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index c9754d1da4..4316ddf8e6 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** @@ -88,3 +89,45 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths } + +/** + * Trailing path segment, the part that identifies the file at a glance. + * @param path - Slash- or backslash-separated path. + * @returns The final segment, or the whole string when separator-free. + */ +export function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * File-mention vocabulary over one turn's produced paths, for the closing + * message's prose: an inline-code token opens the file it names. A token + * resolves by exact path, or by being exactly the basename of exactly one + * produced path — a basename two paths share stays inert rather than + * guessing, so a mention link can never open the wrong file or 404. + * @param paths - The turn's produced paths (tool order, already deduped). + * @param openFile - The chat view's file opener. + * @param label - Localizes the accessible open-label for a resolved path. + * @returns The resolver MarkdownText consumes; the full path rides `title`, + * the same disambiguator the row's chips carry. + */ +export function producedFileMentions( + paths: readonly string[], + openFile: (path: string) => void, + label: (path: string) => string, +): MarkdownFileMentions { + return { + resolve(value) { + const path = paths.includes(value) ? value : onlyPathWithBasename(paths, value) + if (path === undefined) return undefined + return { open: () => { openFile(path) }, label: label(path), title: path } + }, + } +} + +/** The single produced path whose basename is exactly `value`, else undefined. */ +function onlyPathWithBasename(paths: readonly string[], value: string): string | undefined { + const matches = paths.filter(path => basename(path) === value) + return matches.length === 1 ? matches[0] : undefined +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 8a7514d644..3bcaffe605 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -13,9 +13,10 @@ import type { AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-conversation/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' +import { basename, producedFileMentions, producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -142,6 +143,33 @@ describe('ProducedFiles row', () => { }) }) +describe('producedFileMentions resolver', () => { + const label = (path: string) => `打开 ${path}` + + it('resolves exact paths and unique basenames; ambiguity and unknowns stay unresolved', () => { + const opened: string[] = [] + const resolver = producedFileMentions( + ['out/index.html', 'a/style.css', 'b/style.css'], + (path) => { opened.push(path) }, + label, + ) + // Unique basename resolves to its full path; the full path rides title. + const byBasename = resolver.resolve('index.html') + expect(byBasename?.label).toBe('打开 out/index.html') + expect(byBasename?.title).toBe('out/index.html') + byBasename?.open() + expect(opened).toEqual(['out/index.html']) + // An exact path resolves even when its basename is ambiguous. + const exact = resolver.resolve('a/style.css') + expect(exact?.title).toBe('a/style.css') + // A basename two paths share stays unresolved rather than guessing, + // and so does a token naming nothing the turn wrote. + expect(resolver.resolve('style.css')).toBeUndefined() + expect(resolver.resolve('notes.md')).toBeUndefined() + expect(basename('a\\b\\c.txt')).toBe('c.txt') + }) +}) + describe('package shells', () => { it('the node half mounts inert and the invariant companion registers ownership', async () => { // The node half is deliberately inert; mounting it must simply not throw. @@ -173,7 +201,24 @@ describe('plugin registration', () => { await fiber.await() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + // The prose face is live while the plugin is: a produced turn yields a + // resolver whose matches open through the owner-supplied opener. + const opened: string[] = [] + const owner = { + nodes: [user(1, 'go'), wrote(2, 'w', 'site/report.html'), assistant(3, 'done', 1)], + seq: 3, + openFile: (path: string) => { opened.push(path) }, + } + const service = (ctx as unknown as { get(name: string): ChatFileMentions | undefined }).get('chatFileMentions') + const mentions = service?.forClosing(owner) + mentions?.resolve('report.html')?.open() + expect(opened).toEqual(['site/report.html']) + // A turn that produced nothing yields no vocabulary at all. + expect(service?.forClosing({ ...owner, nodes: [user(1, 'hi'), assistant(2, 'ok', 1)], seq: 2 })).toBeUndefined() + await fiber.dispose() expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. + expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) }) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 29ddfa1014..42211f6d7a 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: fae49d5764d4001f1852cb43aab730064febf2d2 -README.zh.md: 37984b9020df08b8804306111ca13ec0e92e5ce7 +README.md: 098a202a4ac9ee263ce1beaaee7a8624ebf26b80 +README.zh.md: 2b33af3316dede35cb5a226e41365f692a8b25d3 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index fae49d5764..098a202a4a 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -10,7 +10,7 @@ Pure React atoms (zero cordis): StateDot, DisclosureRow, ic_ds_* icons, Button/P ## Markdown rendering -`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). +`MarkdownText` renders GFM and `$…$`, `$$…$$`, `\(…\)`, and `\[…\]` TeX math from untrusted assistant output through React elements, with math typeset by KaTeX and trusted commands disabled; block-level same-line `$$…$$` is display math, including `\tag{}`. A narrow micromark extension lets asterisk strong emphasis ending in punctuation close before adjacent CJK text, where prose normally omits the whitespace CommonMark requires; single-asterisk emphasis, non-CJK adjacency, escapes, code, and math retain upstream parsing. It omits raw HTML, neutralizes relative and non-HTTP(S)/mailto links, opens HTTP(S) links with safe external-link attributes, and renders absolute HTTP(S) images without a referrer; relative paths, absolute local paths, `file:` URLs, and unsupported schemes retain their alt text. Inline code whose complete value is an absolute HTTP(S) URL keeps its code styling and gains the same safe external anchor; commands, partial URLs, other schemes, and fenced code remain inert. An optional `fileMentions` resolver lets the owning view link inline code that names a real file: the token keeps code styling and gains a button wired to the resolved opener, with the resolver's accessible label and full-path `title`. The renderer never guesses at what looks like a path — an unresolved token stays inert, mentions apply to settled renders only (the streaming cache must not bake in handlers that could go stale), and a token inside an anchor stays inert because a button cannot nest there. While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail behind them re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply ([mechanism and DOM-parity contract](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md)). `MessageText` remains the literal-text primitive for user-authored content. `extractMarkdownPlainText` removes Markdown presentation markup for compact labels while preserving raw HTML as literal text. Element spacing, responsive images, tables, links, and inline code use the same `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` tokens as deepsuite `@deepseek/md`. Fenced blocks render through `CodeBlock` (language banner, copy control, shiki for the registered grammars). ## Terminal output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 37984b9020..2b33af3316 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -10,7 +10,7 @@ ## Markdown 渲染 -`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 +`MarkdownText` 通过 React 元素渲染来自不受信任 assistant 输出的 GFM 与 `$…$`、`$$…$$`、`\(…\)` 和 `\[…\]` TeX 公式,公式由 KaTeX 排版并禁用受信任命令;块级同一行 `$$…$$` 是显示公式并支持 `\tag{}`。一个小范围的 micromark 扩展允许由星号标记、以标点结尾的粗体在紧邻的 CJK 文本前闭合,以适应 CJK 文本通常省略 CommonMark 所要求空格的写法;单星号强调、紧邻非 CJK 文本的情况、转义、代码与数学公式仍沿用上游解析行为。它会省略原始 HTML,使相对链接及非 HTTP(S)/mailto 链接失效,以安全的外部链接属性打开 HTTP(S) 链接,并在不发送 referrer 的情况下渲染采用绝对 HTTP(S) URL 的图片;相对路径、绝对本地路径、`file:` URL 与不受支持的 scheme 会保留其 alt 文本。完整内容为绝对 HTTP(S) URL 的行内代码会保留代码样式,并获得同样安全的外部链接;命令、非完整 URL、其他 scheme 与围栏代码仍不会成为链接。可选的 `fileMentions` 解析器让持有视图把命名真实文件的行内代码变成链接:token 保留代码样式并获得接到所解析 opener 的按钮,带解析器给出的无障碍标签与完整路径 `title`。渲染器绝不猜测什么长得像路径——未解析的 token 保持原样,提及只作用于已定稿的渲染(流式缓存不能烘进可能过期的 handler),锚点内部的 token 也保持原样,因为按钮不能嵌套在链接里。回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复([机制与 DOM 一致性契约](../../../.agents/notes/implemented/architecture/2026-08-06-web-markdown-incremental-ast-renderer.md))。`MessageText` 仍是用户创作内容使用的字面文本原语。`extractMarkdownPlainText` 会移除 Markdown 呈现标记以用于紧凑标签,同时将原始 HTML 保留为字面文本。元素间距、响应式图片、表格、链接与行内代码使用与 deepsuite `@deepseek/md` 相同的 `--dsw-alias-markdown-*` / `--dsw-font-markdown-*` token。围栏代码块通过 `CodeBlock` 渲染(语言横幅、复制控件,以及对已注册语法使用 shiki)。 ## 终端输出 diff --git a/packages/client/ui-primitives/src/index.ts b/packages/client/ui-primitives/src/index.ts index 9aac5da45f..18e5b2c7e9 100644 --- a/packages/client/ui-primitives/src/index.ts +++ b/packages/client/ui-primitives/src/index.ts @@ -42,7 +42,7 @@ export { CodeBlock } from './markdown/CodeBlock.tsx' export type { CodeBlockProps } from './markdown/CodeBlock.tsx' export { JsonBlock } from './markdown/JsonBlock.tsx' export { MarkdownText } from './markdown/MarkdownText.tsx' -export type { MarkdownCodeLabels } from './markdown/MarkdownText.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './markdown/MarkdownText.tsx' export { MessageText } from './markdown/MessageText.tsx' export { extractMarkdownPlainText } from './markdown/plain-text.ts' export type { MarkdownPlainTextMode, MarkdownPlainTextOptions } from './markdown/plain-text.ts' diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css index 7e31857053..b62e66e86e 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.module.css +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.module.css @@ -241,3 +241,25 @@ background: var(--dsw-alias-bg-base); object-fit: contain; } + +/* Inline file mention: the same link language this sheet gives anchors (and + thereby URL-promoted inline code) — link-blue at rest, underline only on + hover/focus. An underline at rest reads badly inside the code chip, where + it collides with monospace descenders and the pill background. */ +.fileMention { + margin: 0; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--dsw-alias-state-business-primary); + text-decoration: none; + cursor: pointer; +} + +.fileMention:hover, +.fileMention:focus { + outline: none; + text-decoration: underline var(--dsw-alias-state-business-primary); + text-underline-offset: 3px; +} diff --git a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx index bb4b62514c..4dff78d784 100644 --- a/packages/client/ui-primitives/src/markdown/MarkdownText.tsx +++ b/packages/client/ui-primitives/src/markdown/MarkdownText.tsx @@ -19,20 +19,25 @@ import { collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection, wrapBlockChildren, } from './render.tsx' -import type { MarkdownCodeLabels, MarkdownRenderContext, ReferenceTargets } from './render.tsx' +import type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownRenderContext, ReferenceTargets } from './render.tsx' import 'katex/dist/katex.min.css' import css from './MarkdownText.module.css' -export type { MarkdownCodeLabels } from './render.tsx' +export type { MarkdownCodeLabels, MarkdownFileMentions } from './render.tsx' /** One settled full render: parse with math, resolve references, append the footnote section. */ -function renderSettled(text: string, codeLabels: MarkdownCodeLabels | undefined): ReactNode[] { +function renderSettled( + text: string, + codeLabels: MarkdownCodeLabels | undefined, + fileMentions: MarkdownFileMentions | undefined, +): ReactNode[] { const root = parseGfmWithMath(text) const targets = createReferenceTargets() collectReferenceTargets(root.children, targets) const context: MarkdownRenderContext = { streaming: false, codeLabels, + fileMentions, targets, footnoteOrder: [], footnoteCounts: new Map(), @@ -96,6 +101,7 @@ class StreamingRenderer { const frozenContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: this.frozenFootnoteOrder, footnoteCounts: this.frozenFootnoteCounts, @@ -113,6 +119,7 @@ class StreamingRenderer { const tailContext: MarkdownRenderContext = { streaming: true, codeLabels: this.codeLabels, + fileMentions: undefined, targets: frameTargets, footnoteOrder: [...this.frozenFootnoteOrder], footnoteCounts: new Map(this.frozenFootnoteCounts), @@ -137,28 +144,33 @@ class StreamingRenderer { * the finalize swap) and parses incrementally across chunks; `codeLabels` * forwards localized copy-button labels to fence CodeBlocks — pass a * reference-stable object (memoized per locale revision), because a new - * identity discards the streaming render cache mid-message. + * identity discards the streaming render cache mid-message. `fileMentions` + * links inline-code tokens its resolver recognizes as real files; this is + * the single streaming gate — it applies to settled renders only, because a + * streaming message's vocabulary is not final and frozen cached elements + * must not bake in handlers that could go stale. * @returns A GFM document with TeX math rendered through KaTeX; raw HTML, * relative links, and unsafe protocols are disabled, while absolute HTTP(S) * images render directly. */ -export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels }: { +export const MarkdownText = memo(function MarkdownText({ text, streaming = false, codeLabels, fileMentions }: { text: string streaming?: boolean codeLabels?: MarkdownCodeLabels | undefined + fileMentions?: MarkdownFileMentions | undefined }) { const streamRef = useRef(null) const streamLabelsRef = useRef(codeLabels) const children = useMemo(() => { if (!streaming) { streamRef.current = null - return renderSettled(text, codeLabels) + return renderSettled(text, codeLabels, fileMentions) } if (streamRef.current === null || streamLabelsRef.current !== codeLabels) { streamRef.current = new StreamingRenderer(codeLabels) streamLabelsRef.current = codeLabels } return streamRef.current.render(text) - }, [text, streaming, codeLabels]) + }, [text, streaming, codeLabels, fileMentions]) return
{children}
}) diff --git a/packages/client/ui-primitives/src/markdown/render.tsx b/packages/client/ui-primitives/src/markdown/render.tsx index 4ac8b4dbc1..452e0fc475 100644 --- a/packages/client/ui-primitives/src/markdown/render.tsx +++ b/packages/client/ui-primitives/src/markdown/render.tsx @@ -99,6 +99,21 @@ export function collectReferenceTargets( } } +/** + * File-mention affordance for inline code: the owner resolves an authored + * token to the file it names, using its own vocabulary of real files — the + * renderer never guesses at what looks like a path. + */ +export interface MarkdownFileMentions { + /** + * Resolve one inline-code token. + * @param value - The authored token, exactly as written. + * @returns The opener with its accessible label and full-path title, or + * undefined when the token names no known file — it then stays inert code. + */ + resolve(value: string): { open: () => void; label: string; title: string } | undefined +} + /** * One render pass's state: immutable options and targets plus the footnote * numbering accumulated in document order while references render. @@ -108,6 +123,10 @@ export interface MarkdownRenderContext { readonly streaming: boolean /** Localized fence copy-button labels. */ readonly codeLabels: MarkdownCodeLabels | undefined + /** Inline-code file mentions; absent wherever no opener vocabulary exists. */ + readonly fileMentions: MarkdownFileMentions | undefined + /** Inside an anchor's children: interactive mentions must not nest there. */ + readonly inLink?: boolean /** Reference targets visible to this pass. */ readonly targets: ReferenceTargets /** Footnote identifiers in first-reference order; a footnote's number is its 1-based index here. */ @@ -217,7 +236,27 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte // authored text, not a parsed destination, so no normalizeUri: port, // path, and query render unchanged. const href = inlineCodeHttpUrl(value) - return {href === undefined ? value : renderSafeLink(href, [value], 'link')} + if (href !== undefined) return {renderSafeLink(href, [value], 'link')} + // A token the owner's file-mention vocabulary recognizes opens that + // file; the resolver, not this renderer, decides what names a file. + // Inside an anchor the token stays inert — a button cannot nest there. + const mention = context.inLink === true ? undefined : context.fileMentions?.resolve(value) + if (mention !== undefined) { + return ( + + + + ) + } + return {value} } case 'html': // No HTML parser enters the pipeline: raw HTML stays literal text. @@ -236,7 +275,7 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte case 'table': return renderTable(node, key, context) case 'link': - return renderAnchor(node.url, renderChildren(node.children, context), key) + return renderAnchor(node.url, renderChildren(node.children, { ...context, inLink: true }), key) case 'linkReference': return renderLinkReference(node, key, context) case 'image': @@ -460,14 +499,14 @@ function renderLinkReference( context: MarkdownRenderContext, ): ReactNode { const definition = context.targets.definitions.get(node.identifier.toUpperCase()) - const children = renderChildren(node.children, context) if (definition === undefined) { // The grammar only emits references whose definitions exist somewhere in // the same parse, but incremental segments and hand-built trees may still - // present unresolved ones: revert to the bracketed source text. - return {'['}{children}{referenceSuffix(node)} + // present unresolved ones: revert to the bracketed source text — which is + // not an anchor, so mentions inside it stay live. + return {'['}{renderChildren(node.children, context)}{referenceSuffix(node)} } - return renderAnchor(definition.url, children, key) + return renderAnchor(definition.url, renderChildren(node.children, { ...context, inLink: true }), key) } function renderImageReference( diff --git a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx index 48dd03f5c5..48f59c7479 100644 --- a/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown-render-units.spec.tsx @@ -20,6 +20,7 @@ function makeContext(): MarkdownRenderContext { return { streaming: false, codeLabels: undefined, + fileMentions: undefined, targets: createReferenceTargets(), footnoteOrder: [], footnoteCounts: new Map(), diff --git a/packages/client/ui-primitives/tests/markdown.spec.tsx b/packages/client/ui-primitives/tests/markdown.spec.tsx index 5066e32cd2..89ffd32346 100644 --- a/packages/client/ui-primitives/tests/markdown.spec.tsx +++ b/packages/client/ui-primitives/tests/markdown.spec.tsx @@ -148,6 +148,49 @@ describe('MarkdownText', () => { expect(container.querySelector('pre code a')).toBeNull() }) + it('links inline code through the file-mention resolver: URL first, settled only, never inside links', () => { + const opened: string[] = [] + const fileMentions = { + resolve: (value: string) => value === 'index.html' || value === 'out/index.html' + ? { open: () => { opened.push(value) }, label: 'Open out/index.html', title: 'out/index.html' } + : undefined, + } + const source = [ + '`index.html`', + '`other.css`', + '`https://example.com/`', + // Inside an anchor the mention stays inert code: a button cannot nest there. + '[see `out/index.html`](https://example.com/doc)', + '[ref `out/index.html`][target]', + '[target]: https://example.com/ref', + '```', + 'index.html', + '```', + ].join('\n\n') + const { container } = render() + + const mention = screen.getByRole('button', { name: 'Open out/index.html' }) + expect(mention.closest('code')).not.toBeNull() + // The full path rides title, the same disambiguator the row's chips carry. + expect(mention.getAttribute('title')).toBe('out/index.html') + fireEvent.click(mention) + expect(opened).toEqual(['index.html']) + // Exactly one live mention: the two inside anchors declined, and an + // unresolved token plus fenced code stay inert. + expect(container.querySelectorAll('code button')).toHaveLength(1) + expect(container.querySelectorAll('a code button, a button')).toHaveLength(0) + expect(screen.getByText('other.css').closest('button')).toBeNull() + // URL promotion wins before the resolver sees a token. + expect(screen.getByText('https://example.com/').closest('a')).not.toBeNull() + + // Streaming renders keep mentions off — the one gate lives here: cached + // frozen elements must not bake in handlers that could go stale. + const streamed = render( + , + ) + expect(streamed.container.querySelector('button')).toBeNull() + }) + it('exposes the CJK strong syntax as a micromark extension needing CommonMark attention markers', () => { const extension = cjkFriendlyStrong() expect(cjkFriendlyStrong()).toBe(extension) diff --git a/packages/core/agent-loop/README.i18n.yaml b/packages/core/agent-loop/README.i18n.yaml index b25a0f18f1..2f165e76e4 100644 --- a/packages/core/agent-loop/README.i18n.yaml +++ b/packages/core/agent-loop/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/agent-loop/README.md -README.md: ec1948506bbaf7a3416c2031fb472a9b513b500f -README.zh.md: 5828da301b35c95719286fb942ac239769539b67 +README.md: 2fdc60086bebc924089b5b8bd12f4b4456b1ead5 +README.zh.md: df0ba44ec5b010666737c0568bc5eeb220ff0d96 diff --git a/packages/core/agent-loop/README.md b/packages/core/agent-loop/README.md index ec1948506b..2fdc60086b 100644 --- a/packages/core/agent-loop/README.md +++ b/packages/core/agent-loop/README.md @@ -67,7 +67,7 @@ Every provider call that reaches a successful finish appends exactly one `assist After `agent/request` returns a provider/model call config, the loop asks `ctx.llm.prepareCall()` to validate adapter-owned fields and materialize configured reasoning-effort and output-token defaults under the active turn signal. The prepared call retains the exact adapter registration across this asynchronous resolution, `request/header` logging, and terminal dispatch, so HMR cannot mix one adapter's capability result with another adapter's request. The header records the effective config and which fields came from the adapter. Before the next waterfall, the loop removes those marked fields from the proposal so the current exact route rematerializes its own defaults; unmarked explicit settings persist across steps and route changes. A route with no registered adapter preserves the proposed config so an `llm/stream` listener can own and short-circuit it; unhandled terminal dispatch still fails with `NO_ADAPTER`. A new loop instance applies the same provenance rule when resuming. -Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) owns the lifecycle and race contract. +Plugin failure ends the current turn, not the loop. Final adapter selection, dispatch, and iteration failures arrive from `ctx.llm` as terminal error or aborted finishes and enter `agent/request-error`; middleware, result processing, tools, and other extension failures remain thrown and close directly. Recovery receives request coordinates, immutable provider facts, the immutable retry policy captured by the prepared adapter registration, and the turn signal; the policy is absent when middleware owns an unprepared route. A handling listener returns `{ kind: 'retry' }`; an unhandled failure is terminal. AgentLoop owns one cancellation signal for the current admission or turn. An effective `cancel(cause)` clears pending work unless `keepInbox` is set and cooperatively aborts that signal; idle cancellation is a no-op. Waking input that lands after the abort fires but before the activity converges to idle is latched (`wakeRequested`) and replayed at the driver's own convergence boundary, so it runs without a further waking send; a `disposed` cancel never latches, and a wake submitted while already idle always opens its turn boundary (status shows a transient `idle → running → idle` pair even when the message was cleared). Durable `turn/end` records `aborted` for `user` and `parent`, while disposal records `disposed`; undispatched model tool calls receive synthetic `tool/call` and `ABORTED_BEFORE_DISPATCH` result pairs. The cancellation cause changes reporting, not how result context finalized after cancellation is handled. Disposal waits for signal-ignoring work before registry removal. The [explicit-cancellation decision](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md) and the [cancel-convergence wake latch](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md) own the lifecycle and race contract. Within a step, exclusive calls form barriers; parallel-safe calls use a bounded rolling pool and are reclassified before start. Only dispatch/body overlaps. Policy, durable results, and result context remain model-ordered. Abort stops new calls, drains started results, and retains their finalized result context without distinguishing the cancellation cause. An internal scheduler failure stops new dispatches, waits for already-started dispatches, and reaches the turn error boundary without fabricating tool results. diff --git a/packages/core/agent-loop/README.zh.md b/packages/core/agent-loop/README.zh.md index 5828da301b..df0ba44ec5 100644 --- a/packages/core/agent-loop/README.zh.md +++ b/packages/core/agent-loop/README.zh.md @@ -67,7 +67,7 @@ interface Config { 在 `agent/request` 返回提供方/模型调用配置后,循环会调用 `ctx.llm.prepareCall()`,在活跃轮次信号的控制下校验由适配器持有的字段,并填入配置的推理(reasoning)强度和输出 token 默认值。准备完成的调用会在这次异步解析、`request/header` 日志记录和最终分派期间保留同一项确切的适配器注册,因此 HMR(热模块替换)不会把某个适配器的能力解析结果与另一适配器的请求混用。请求 header 会记录生效配置以及哪些字段来自适配器。下一次 waterfall(瀑布式事件)前,循环会从提议中移除这些带标记字段,使当前精确路由重新填入自身默认值;未带标记的显式设置会跨步骤和路由变化保留。没有已注册适配器的路由会保留原定配置,使 `llm/stream` 监听器可以接管并短路该请求;最终分派仍会以 `NO_ADAPTER` 拒绝未得到处理的路由。新循环实例在恢复时会应用同一来源规则。 -插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)规定生命周期与竞态契约。 +插件失败会结束当前轮次,而不是结束循环。最终适配器选择、分发与迭代失败会由 `ctx.llm` 作为终止 error 或 aborted finish 返回,并进入 `agent/request-error`;middleware、结果处理、工具及其他扩展失败仍会抛出并直接关闭轮次。恢复逻辑会接收请求坐标、不可变的提供方事实、准备完成的适配器注册所捕获的不可变重试策略以及轮次信号;middleware 接管未准备路由时,该策略缺失。处理失败的监听器返回 `{ kind: 'retry' }`;未被处理的失败是终态。AgentLoop 为当前接纳或轮次拥有一个取消信号。有效的 `cancel(cause)` 在未设置 `keepInbox` 时清除待处理工作,并以协作方式中止该信号;空闲取消是空操作。abort 触发后、活动收敛到空闲前到达的唤醒输入会被锁存(`wakeRequested`),并在 driver 自身的收敛边界重放,无需再发一条唤醒 send 即可执行;`disposed` 取消从不锁存,而 agent 已处于空闲时发送的唤醒总是打开自己的 turn 边界(即使消息已被清除,状态也会显示瞬态 `idle → running → idle` 对)。持久 `turn/end` 为 `user` 和 `parent` 记录 `aborted`,dispose(资源释放)则记录 `disposed`;未分发的模型工具调用会收到合成的 `tool/call` 与 `ABORTED_BEFORE_DISPATCH` 结果对。取消原因只改变报告方式,不改变对取消后已定案结果上下文的处理。dispose 会等待忽略信号的工作完成,然后才从注册表移除。[显式取消决策](../../../.agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md)与[取消收敛窗口唤醒锁存](../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)规定生命周期与竞态契约。 在步骤内,独占调用形成屏障;并行安全调用使用有界滚动池,并在启动前重新分类。只有分发/主体会重叠。策略、持久结果和结果上下文仍保持模型顺序。中止会停止新调用,drain 已启动的结果,并保留其已定案的结果上下文,不区分取消原因。内部调度器故障会停止新的分发,等待已启动的分发,然后在不虚构工具结果的情况下到达轮次错误边界。 diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index cfac8262c2..6ef965e59e 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -43,7 +43,7 @@ type Phase = lastTurn: number wakeRequested: boolean } - | { kind: 'running'; abort: AbortController; turn: number; step: number } + | { kind: 'running'; abort: AbortController; turn: number; step: number; wakeRequested: boolean } type StepEndReason = Extract @@ -112,10 +112,11 @@ export class ReactLoopAgent implements Agent { send(message: UserMessage, target: InboxTarget, wakeup: boolean): void { // Waking input cannot join an aborted activity, so it starts the next turn. + // Captured before the insertion so a reentrant cancel from a splice observer cannot reclassify it. const wakingAfterAbort = wakeup && this.phase.kind !== 'idle' && this.phase.abort.signal.aborted const resolvedTarget = wakingAfterAbort ? 'next-turn' : target this.inbox.splice(resolvedTarget, Infinity, 0, [message]) - if (wakeup) this.wakeDriver() + if (wakeup) this.wakeDriver(wakingAfterAbort) } followup(input: UserMessage): void { @@ -133,7 +134,7 @@ export class ReactLoopAgent implements Agent { cancel(cause: AgentCancelCause, options: CancelOptions = {}): void { if (!options.keepInbox) { this.inbox.clear() - if (this.phase.kind === 'maintenance') this.phase.wakeRequested = false + if (this.phase.kind !== 'idle') this.phase.wakeRequested = false } if (this.phase.kind !== 'idle') this.phase.abort.abort(cause) } @@ -154,22 +155,40 @@ export class ReactLoopAgent implements Agent { return await task(maintenance.abort.signal) } finally { this.setPhase({ kind: 'idle', lastTurn: maintenance.lastTurn }) - if (maintenance.wakeRequested) this.wakeDriver() + if (maintenance.wakeRequested && this.inbox.hasPending) this.wakeDriver() done.resolve() } })() } - /** Start one driver, or remember its wake behind maintenance. */ - private wakeDriver(): void { - if (this.phase.kind === 'maintenance') { - if (!this.phase.abort.signal.aborted) this.phase.wakeRequested = true + /** + * Start one driver, or latch its wake behind maintenance or an aborted + * activity. A wake sent while idle always opens its turn boundary, even + * when its message was cleared; only a latched replay is suppressed when + * the queue no longer holds the wake. + * @param wakeAfterAbort - the {@link send} classification, captured before + * the inbox insertion so a reentrant cancel cannot reclassify it. + */ + private wakeDriver(wakeAfterAbort = false): void { + if (this.phase.kind !== 'idle') { + // Maintenance and aborted drivers cannot deliver the wake: latch it for + // replay at convergence. Live drivers claim queued work themselves; + // disposal never latches, so teardown waits on no model turn. + const reason = this.phase.abort.signal.reason as AgentCancelCause | undefined + if (reason?.kind !== 'disposed' && (this.phase.kind === 'maintenance' || wakeAfterAbort)) { + this.phase.wakeRequested = true + } return } - if (this.phase.kind !== 'idle') return const driver = Promise.withResolvers() this.activityDone = driver.promise - this.setPhase({ kind: 'running', abort: new AbortController(), turn: this.phase.lastTurn, step: 0 }) + this.setPhase({ + kind: 'running', + abort: new AbortController(), + turn: this.phase.lastTurn, + step: 0, + wakeRequested: false, + }) this.loopCtx.agents.withInitiator(this, () => this.kick()).then(driver.resolve, driver.reject) } @@ -196,7 +215,9 @@ export class ReactLoopAgent implements Agent { } finally { /* v8 ignore next -- kick owns a running phase until this driver boundary */ if (this.phase.kind === 'running') { - this.setPhase({ kind: 'idle', lastTurn: this.phase.turn }) + const { turn, wakeRequested } = this.phase + this.setPhase({ kind: 'idle', lastTurn: turn }) + if (wakeRequested && this.inbox.hasPending) this.wakeDriver() } } } @@ -302,6 +323,8 @@ export class ReactLoopAgent implements Agent { } if (!this.inbox.hasPending) return false phase.abort = new AbortController() + // A fresh controller makes a latch set on the old one stale: the live driver claims the queue itself. + phase.wakeRequested = false phase.step = 0 return true } diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 5c0deed621..79bc65a105 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -126,6 +126,121 @@ describe('Agent.cancel()', () => { expect(adapter.requests).toHaveLength(3) }) + it('cancel({ keepInbox: true }) latches a waking send landing in the abort-to-idle window', async () => { + const adapter = new MockAdapter(['hang', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-window'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // The abort signal is set but the driver has not converged to idle yet: + // the waking send must be latched, not parked until another wake. + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.session.events.filter(e => e.type === 'turn/end').map(e => + e.type === 'turn/end' ? e.data.reason : null)).toEqual([ + { kind: 'aborted', reason: { kind: 'user' } }, + { kind: 'completed' }, + ]) + }) + + it('cancel() without keepInbox clears a latched wake alongside the inbox', async () => { + const adapter = new MockAdapter(['hang', textResponse('C reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('latch-cleared'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + agent.cancel({ kind: 'user' }, { keepInbox: true }) + send(agent, 'B') // latched behind the aborted activity + agent.cancel({ kind: 'user' }) // drops the inbox and the latch with it + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + + send(agent, 'C') + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['active', 'C']) + expect(adapter.requests).toHaveLength(2) + }) + + it('removing the latched wake before convergence suppresses the replay', async () => { + const adapter = new MockAdapter(['hang']) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('removed-latched-wake'), { provider: 'mock', model: 'mock' }) + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + const steer = createUserMessage({ content: [{ type: 'text', text: 'steer me' }], source: { kind: 'user' } }) + agent.steer(steer) // latched behind the aborted activity + agent.inbox.remove(steer.id) // the wake is retracted before convergence + + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['active']) + expect(adapter.requests).toHaveLength(1) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(agent.status).toBe('idle') + // No replay with nothing to run: the latched message is gone, so no + // empty follow-up turn is recorded. + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(1) + }) + + it('latches a wake arriving deep into a slow abort convergence', async () => { + // The stream notices the abort only after 50ms, so the driver stays in + // the abort-to-idle window long after `cancel()` returned: the wake must + // be latched across the whole window, not just the same-tick case. + const adapter = new MockAdapter(['hang-slow', textResponse('B reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('slow-convergence'), { provider: 'mock', model: 'mock' }) + + send(agent, 'A') + await new Promise(resolve => setTimeout(resolve, 30)) + + agent.cancel({ kind: 'user' }, { keepInbox: true }) + await new Promise(resolve => setTimeout(resolve, 10)) + send(agent, 'B') + + await agent.whenIdle() + expect(userTexts(agent)).toEqual(['A', 'B']) + expect(adapter.requests).toHaveLength(2) + expect(agent.inbox.nextTurn).toHaveLength(0) + }) + + it('does not latch a wake landing after disposal begins', async () => { + const adapter = new MockAdapter(['hang-slow', textResponse('late reply')]) + const ctx = await harness(adapter) + const handle = await ctx.agents.create({ + sessionId: SessionId('dispose-window-wake'), + agentOptions: { provider: 'mock', model: 'mock' }, + }) + const agent = handle.agent + + send(agent, 'active') + await new Promise(resolve => setTimeout(resolve, 30)) + + // Dispose cancels with `{ kind: 'disposed' }`; a wake landing in the + // abort-to-idle window must not latch, so `whenIdle()` does not wait on + // a model turn over the session being torn down. + const disposal = handle.dispose() + setTimeout(() => { send(agent, 'late wake') }, 10) + await disposal + + expect(adapter.requests).toHaveLength(1) + expect(userTexts(agent)).toEqual(['active']) + }) + it('cancel after waking send closes its synchronously opened turn without a step', async () => { const adapter = new MockAdapter([textResponse('should not run')]) const ctx = await harness(adapter) @@ -228,7 +343,7 @@ describe('Agent.cancel()', () => { expect(userTexts(agent)).toEqual(['first', 'later']) }) - it('replacement work queued after idle-listener cancellation waits for another wakeup', async () => { + it('replacement work queued after idle-listener cancellation replays at convergence', async () => { const adapter = new MockAdapter([ textResponse('first reply'), textResponse('replacement reply'), @@ -253,9 +368,11 @@ describe('Agent.cancel()', () => { if (replacementIdle === undefined) throw new Error('idle listener did not register replacement work') await replacementIdle - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['first']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The wake sent after the cancel fired is latched: the surviving + // replacement runs at convergence without a third message. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['first', 'surviving replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const idle = waitForIdle(ctx, agent) send(agent, 'wake it') @@ -479,7 +596,7 @@ describe('Agent.cancel()', () => { expect(agent.session.events.some(e => e.type === 'turn/start')).toBe(false) }) - it('a running-listener cancellation parks replacement work until another wakeup', async () => { + it('a running-listener cancellation replays replacement work at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -497,17 +614,20 @@ describe('Agent.cancel()', () => { await idle dispose() - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + // B's wake was latched behind the cancelled driver: it runs on its own. + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(2) }) - it('a prompt queued during pre-step cancellation waits for another wakeup', async () => { + it('a prompt queued during pre-step cancellation replays at convergence', async () => { const adapter = new MockAdapter([textResponse('A reply'), textResponse('B reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('a1'), { provider: 'mock', model: 'mock' }) @@ -518,13 +638,15 @@ describe('Agent.cancel()', () => { send(agent, 'B') await idle - expect(userTexts(agent)).toEqual([]) - expect(agent.inbox.nextTurn).toHaveLength(1) + expect(userTexts(agent)).toEqual(['B']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'C') await replacementIdle expect(userTexts(agent)).toEqual(['B', 'C']) + expect(adapter.requests).toHaveLength(2) expect(agent.session.events.filter(event => event.type === 'turn/end')).toHaveLength(3) }) @@ -556,7 +678,7 @@ describe('Agent.cancel()', () => { expect(flat).not.toContain('steer text') }) - it('parks replacement work queued synchronously by an abort observer', async () => { + it('replays replacement work queued synchronously by an abort observer', async () => { const adapter = new MockAdapter([ 'hang', textResponse('replacement reply'), @@ -586,13 +708,15 @@ describe('Agent.cancel()', () => { }), ]) - expect(adapter.requests).toHaveLength(1) - expect(userTexts(agent)).toEqual(['original']) - expect(agent.inbox.nextTurn).toHaveLength(1) + // The abort-observer wake was latched: replacement runs at convergence, + // so the original turn is followed by a completed replacement turn. + expect(adapter.requests).toHaveLength(2) + expect(userTexts(agent)).toEqual(['original', 'replacement']) + expect(agent.inbox.nextTurn).toHaveLength(0) const reasons = agent.session.events .filter(event => event.type === 'turn/end') .map(event => event.type === 'turn/end' ? event.data.reason : undefined) - expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }]) + expect(reasons).toEqual([{ kind: 'aborted', reason: { kind: 'user' } }, { kind: 'completed' }]) const replacementIdle = waitForIdle(ctx, agent) send(agent, 'wake it') diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c8d048ca47..1173607ae9 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -41,6 +41,14 @@ function send(agent: Agent, text: string) { agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })) } +/** All user-message texts recorded in the log (to assert what actually ran). */ +function userTexts(agent: Agent): string[] { + return agent.session.events + .filter(e => e.type === 'user/message') + .flatMap(e => e.type === 'user/message' ? e.data.content : []) + .flatMap(b => b.type === 'text' ? [b.text] : []) +} + describe('agent loop', () => { it.each([0, -1, 1.5, Number.NaN, Number.MAX_SAFE_INTEGER + 1])( 'rejects invalid AgentOptions.maxTokens %s before publication', @@ -70,7 +78,7 @@ describe('agent loop', () => { }) it('cancels queued wakeup work together with an active maintenance task', async () => { - const adapter = new MockAdapter([textResponse('unused')]) + const adapter = new MockAdapter([textResponse('park reply')]) const ctx = await harness(adapter) const agent = ctx.agentLoop.create(SessionId('cancel-maintenance-wakeup'), { provider: 'mock', @@ -87,15 +95,68 @@ describe('agent loop', () => { }) await started.promise - send(agent, 'discard this wakeup') - agent.cancel({ kind: 'user' }) - send(agent, 'park after cancellation') + send(agent, 'discard this wakeup') // latched behind the live maintenance task + agent.cancel({ kind: 'user' }) // drops the queue and the latch, aborts maintenance + send(agent, 'park after cancellation') // newer intent: re-latched, replays at convergence await expect(maintenance).rejects.toThrow('maintenance aborted') await agent.whenIdle() - expect(agent.inbox.nextTurn).toHaveLength(1) + + // The pre-cancel wakeup is gone; the post-cancel wake replays at convergence. + expect(userTexts(agent)).toEqual(['park after cancellation']) + expect(agent.inbox.nextTurn).toHaveLength(0) + expect(adapter.requests).toHaveLength(1) + }) + + it('replays a wake latched behind maintenance at convergence', async () => { + const adapter = new MockAdapter([textResponse('wake reply')]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-replay'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + send(agent, 'wake behind maintenance') + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual(['wake behind maintenance']) + expect(adapter.requests).toHaveLength(1) + }) + + it('suppresses the replay when a latched maintenance wake is removed', async () => { + const adapter = new MockAdapter([]) + const ctx = await harness(adapter) + const agent = ctx.agentLoop.create(SessionId('maintenance-wake-removed'), { + provider: 'mock', + model: 'mock', + }) + const started = Promise.withResolvers() + const finish = Promise.withResolvers() + const maintenance = agent.runMaintenance(async () => { + started.resolve(undefined) + await finish.promise + }) + await started.promise + + const wake = createUserMessage({ content: [{ type: 'text', text: 'removed wake' }], source: { kind: 'user' } }) + agent.followup(wake) + agent.inbox.remove(wake.id) + finish.resolve(undefined) + await maintenance + await agent.whenIdle() + + expect(userTexts(agent)).toEqual([]) expect(adapter.requests).toEqual([]) - agent.cancel({ kind: 'user' }) + expect(agent.session.events.filter(e => e.type === 'turn/start')).toHaveLength(0) }) it('runs a simple turn: queued message → model → idle, with ordered events', async () => { diff --git a/packages/core/agent-loop/tests/mock-adapter.ts b/packages/core/agent-loop/tests/mock-adapter.ts index 6e592d9311..69dc78990c 100644 --- a/packages/core/agent-loop/tests/mock-adapter.ts +++ b/packages/core/agent-loop/tests/mock-adapter.ts @@ -58,14 +58,16 @@ export function toolCallResponse(rawCallId: string, name: string, args: object, /** * Mock adapter driven by a script: each model call consumes the next entry. * Records every request it receives for assertions. An entry may be a - * function to compute chunks from the request, or a 'hang' marker that - * streams one chunk then waits until aborted. + * function to compute chunks from the request, a 'hang' marker that + * streams one chunk then waits until aborted, or 'hang-slow' which takes + * 50ms to notice the abort — a stand-in for slow real-world teardown + * (LLM stream cancellation, tool unwinding). */ export class MockAdapter extends LlmAdapter { requests: GenerateOptions[] = [] constructor( - private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang')[], + private script: (StreamChunk[] | ((options: GenerateOptions) => StreamChunk[]) | 'hang' | 'hang-slow')[], private readonly reasoning?: LlmModelReasoningInfo, private readonly defaultMaxTokens?: number, ) { @@ -98,6 +100,16 @@ export class MockAdapter extends LlmAdapter { }) return } + if (entry === 'hang-slow') { + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text: 'partial' } + await new Promise((_resolve, reject) => { + const fail = (): void => { reject(new Error('aborted')) } + if (options.signal?.aborted) { setTimeout(fail, 50); return } + options.signal?.addEventListener('abort', () => { setTimeout(fail, 50) }, { once: true }) + }) + return + } const chunks = typeof entry === 'function' ? entry(options) : entry for (const chunk of chunks) { if (options.signal?.aborted) throw new Error('aborted') diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index fae9267347..d8cb5c4114 100644 --- a/packages/core/agent/src/types.ts +++ b/packages/core/agent/src/types.ts @@ -104,7 +104,11 @@ export interface Agent { /** * Route identified input to an inbox boundary and optionally wake the driver. - * Waking input submitted after active cancellation is queued for the next turn. + * Waking input submitted after active cancellation is queued for the next + * turn and runs when the aborted activity converges to idle; a `disposed` + * cancel leaves it parked. A wake submitted while already idle always opens + * its turn boundary, even when its message is cleared before the driver + * claims ([cancel-convergence wake latch](../../../../.agents/notes/implemented/bug-fix/2026-08-07-cancel-convergence-wake-latch.md)). * @param message - identified content and its producer provenance. * @param target - the preferred next-turn or next-step inbox boundary. * @param wakeup - whether delivery may wake the driver. diff --git a/tsconfig.host.json b/tsconfig.host.json index 6641a505b9..81f660c317 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -52,6 +52,7 @@ "apps/web/tests/goal-bar.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", + "apps/web/tests/produced-file-mentions.e2e.ts", "apps/web/tests/subagent-conversation.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts",