diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index 54935a491e..06b4d1d456 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.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-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: c13e40b5b22e5d6fe19f6b5bd137db3a48350390 -2026-07-05-reconstructable-requests.zh.md: 746bcd37dabada949873d394776c1ca43eff4ea2 +2026-07-05-reconstructable-requests.md: d7b867d6e17c2dfe0e49b70bd0f5e7ff173fb572 +2026-07-05-reconstructable-requests.zh.md: c41e5f89558a9c7b09ccd2bf4fd5b7d9a6cde419 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index c13e40b5b2..d7b867d6e1 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -14,7 +14,7 @@ The reference shape for the happy path is MiniCode's `LLMClient`: a stateful con ### The principle -**Model-visible ⟺ logged.** Anything that reaches a model request must be recorded in the session log. The checkable consequence: **every conversation request the loop sends is a pure function of the session log** — anyone holding the log reconstructs it byte-for-byte. Scope, stated precisely: the guarantee covers the loop-built `GenerateOptions`; provider wire bytes follow from it because both adapters' serialization is a pure per-message function at a pinned code version; direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`) and their input is deterministic code over the logged region — reconstructable from log + code, outside the invariant because only the loop marks request ownership. +**Model-visible ⟺ durably referenced.** Anything that reaches a model request must be reconstructable from the session log and the immutable content-addressed objects it references. The checkable consequence: anyone holding the log, its referenced attachment objects, and the pinned code version reconstructs every loop request byte-for-byte. Text-only `GenerateOptions` remain a pure function of the log; image-bearing requests additionally resolve `ImageAttachmentRef` bytes through `ctx.attachments` during adapter serialization, where digest and recorded metadata verification make the object lookup deterministic and fail loud on missing or corrupt data. Direct one-shots (compaction's summarize call) log their envelope scalars (`compact/summary.{provider, model, maxTokens}`), and their input is deterministic code over the logged region plus those referenced objects — outside the invariant because only the loop marks request ownership. Prefix-cache stability is corollary #1, not the headline: an append-only log projected by a per-node pure function yields requests that are append-extensions of their predecessors whenever the header is unchanged — stability is emergent, not managed. Byte-exact audit/replay is corollary #2; resume and fork with *attributable* drift is corollary #3. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 746bcd37da..c41e5f8955 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -14,7 +14,7 @@ Status: implemented ### 原则 -**模型可见 ⟺ 已记录。** 凡到达模型请求的内容都必须记录在会话日志中。可检查的推论:**循环发出的每个对话请求都是会话日志的纯函数**——任何人持有日志即可逐字节重建请求。精确的范围声明:该保证覆盖循环构建的 `GenerateOptions`;提供方协议格式(wire format)字节由此推导而来,因为两个适配器的序列化在固定代码版本下都是逐消息的纯函数;直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域的确定性代码运算——可从日志加代码重建,因为只有循环会标记请求归属,所以它们不在不变式内。 +**模型可见 ⟺ 已持久引用。** 凡到达模型请求的内容都必须能从会话日志及其引用的不可变内容寻址对象中重建。可检查的推论:任何人持有日志、日志引用的附件对象和固定代码版本,即可逐字节重建循环的每个请求。纯文本 `GenerateOptions` 仍是日志的纯函数;含图片请求还会在适配器序列化期间通过 `ctx.attachments` 解析 `ImageAttachmentRef` 字节,其中对内容摘要及已记录元数据的校验使对象查找具有确定性,并在数据缺失或损坏时明确失败。直接的一次性调用(压缩的 summarize 调用)记录其信封标量(`compact/summary.{provider, model, maxTokens}`),其输入是对日志区域及这些引用对象的确定性代码运算——由于只有循环会标记请求归属,因此它们不在不变式内。 前缀缓存稳定性是推论 #1,而非标题:一个仅追加的日志经逐节点纯函数投影,在 header 不变时自然产出前一请求的追加扩展——稳定性是涌现的,不是管理出来的。字节精确的审计/回放是推论 #2;带*可归因*漂移的恢复与 fork 是推论 #3。 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index e7fd7799f0..1c73978183 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.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-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: 92ec665acc745e61f656bd0e57454ad266b722f9 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 7cd96ad9e52c19a005e6bff356dc5159ad3a31bc +2026-07-24-web-config-tree-boot-and-transport-layering.md: 9bf44e398da66ee286fc9bbc1496c002606d1606 +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 23d9bc790c792438cb699952ee802e1ffa89de88 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 92ec665acc..9bf44e398d 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -18,7 +18,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) **Config sources have one declaration place each.** Bundle yml values are engineering defaults, Settings sections are writable user preferences, CLI flags address their owning launcher rows, and env values enter through yml `!!js` expressions. Patches replace a row's config wholesale. The resolved frontend `distIndex` uses that patch channel as an assembly fact. The transport-independent provider/model default belongs to `ctx.agentDefaultModel`; the [direct headless entry point](2026-08-09-headless-direct-core-entry-point.md) and the Web gateway consume the same state. -**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{workspaceRoot?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. +**The transport splits five ways.** `dsh-host-apiproxy` is the gateway plugin (`api-gateway` row): it default-exports `ApiProxyService`, configures only `{nativeOpen?}`, consumes the base layer's entry-point-neutral `ctx.agentDefaultModel`, provides `ctx.apiProxy`, remains transport-agnostic, and registers no routes. `dsh-host-webserver` is a plain route-registration plugin: `HttpServerService` provides `ctx.httpServer` (`register(route) → disposer` with duplicate-pattern throw, `tapIndex` transforms applied in registration order, `port`), listens on activation, answers per-request failures with 400 and logging, and knows no harness concepts. The connection node half owns the `/api` binding from `ctx.apiProxy` through `toFetchHandler`. The modules node half (`ClientModuleHostService`, providing `ctx.clientModuleHost`) owns incremental package scanning, the bundle route, the index tap, and `onRebuilt`/`onGraphChanged` notification. The hmr node half owns dev reload through `fs.watchFile` membership and the `/plugins/events` SSE route. **Package export discipline.** The modules package exposes exactly `.` (node half) and `./client` (the complete browser half: `ClientModuleSystem`, `parseBootManifest`, the adoption plugin face) — no bespoke subpaths; wire types re-export through the root for host-side consumers. The adoption handshake: the kernel writes the constructed instance to `window.__DSH_MODULES__` before cordis exists; the `./client` apply reads the slot (missing = loud throw) and provides `ctx.modules`. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 7cd96ad9e5..23d9bc790c 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -18,7 +18,7 @@ Status: implemented **每个配置源有唯一声明位置。** 组合包 yml 值是工程默认,Settings 分节是可写的用户偏好,CLI(命令行界面)flags 面向其归属的启动器配置行,env 值则通过 yml `!!js` 表达式进入。patch 会整体替换一行的 config。解析后的前端 `distIndex` 通过同一条 patch 通道作为组装事实传递。与传输无关的提供方/模型默认值归 `ctx.agentDefaultModel` 所有;[直接 headless 入口](2026-08-09-headless-direct-core-entry-point.md)与 Web 网关消费同一份状态。 -**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{workspaceRoot?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 +**传输五分。** `dsh-host-apiproxy` 是网关插件(`api-gateway` 行):默认导出 `ApiProxyService`,只配置 `{nativeOpen?}`,消费 base 层不偏向特定入口的 `ctx.agentDefaultModel`,provide `ctx.apiProxy`,保持传输无关且不注册路由。`dsh-host-webserver` 是朴素的路由注册插件:`HttpServerService` provide `ctx.httpServer`(`register(route) → disposer`、重复 pattern 即抛、`tapIndex` 按注册序应用、`port`),激活即 listen,单请求失败时答 400 并记日志,且不认识任何 harness 概念。connection node 半拥有从 `ctx.apiProxy` 经 `toFetchHandler` 绑定到 `/api` 的逻辑。modules node 半(`ClientModuleHostService`,provide `ctx.clientModuleHost`)拥有单包增量扫描、bundle 路由、index tap 与 `onRebuilt`/`onGraphChanged` 通知。HMR node 半通过 `fs.watchFile` membership 与 `/plugins/events` SSE 路由拥有开发期重载。 **包出口纪律。** modules 包只暴露 `.`(node 半)与 `./client`(完整浏览器半:`ClientModuleSystem`、`parseBootManifest`、收编插件面)——不设专用子路径;wire 类型经根出口 re-export 给 host 侧消费方。收编握手:内核在 cordis 之前把建好的实例写入 `window.__DSH_MODULES__`;`./client` 的 apply 读取该槽位(缺少时显式抛错)并 provide `ctx.modules`。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.i18n.yaml new file mode 100644 index 0000000000..d5c2e38a49 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.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-07-29-atomic-web-image-admission.md +2026-07-29-atomic-web-image-admission.md: c09d376f101a41994df3a10c22c06da4e59f06f6 +2026-07-29-atomic-web-image-admission.zh.md: 8785f7489b0c433cba43a1747533b1d38aada3d3 diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md new file mode 100644 index 0000000000..c09d376f10 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.md @@ -0,0 +1,29 @@ +# Agent Note: Atomic Web image admission + +Status: implemented + +English | [中文](2026-07-29-atomic-web-image-admission.zh.md) + +## Problem + +Image prompt admission and `session.selectModel` each read session modality state across asynchronous model and attachment lookups. Without one ordering boundary, an image prompt could validate an image-capable target while a concurrent selection installed a text-only target, or selection could miss a prompt after inbox dequeue but before its durable message event. Scanning the immutable event log avoided the second race but permanently blocked a text-only selection even after compaction removed the image from current model history. + +## Decision + +Each live Web agent has one private promise chain shared by image-bearing prompt admission and model selection. A failed operation settles its caller normally and leaves the chain usable. Text-only prompts bypass the chain because they cannot change the modality constraint. + +The pending-publication set records a queued occurrence at dequeue and a steering occurrence already at enqueue (steering items never enter the queued UI mirror), and retains each until its matching `user/message` or `steering/message` event publishes. If admission ends without publishing, the transition to idle retires the entries; inbox discard retires the listed work, and session disposal retires every remaining entry. Model selection checks that set, the queued UI mirror, and `Session.deriveMessages()`, which is the current model-visible history after compaction. + +Provider adapters remain the final enforcement boundary. The host ordering only prevents its mutable route and pending image state from contradicting each other before request assembly. + +## Alternatives considered + +**Scan every immutable session event.** This catches published images but treats compacted-away content as permanently model-visible, preventing a valid later switch to a text-only route. + +**Retire the pending mirror at inbox dequeue.** Dequeue precedes the durable message append and leaves the exact interval in which model selection can miss both pending and published state. + +**Serialize every prompt and session mutation.** Text-only prompts and unrelated session operations cannot introduce an image requirement. A broader lock would add latency and ownership without closing another modality race. + +## Consequences + +An image prompt and a concurrent model selection have deterministic order, and a text-only target cannot strand an image that has been admitted but not yet published. Selection may wait for an in-flight image admission, while unrelated prompts retain their existing concurrency. Compaction can make a text-only target valid once no pending or derived image remains. diff --git a/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md new file mode 100644 index 0000000000..8785f7489b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-29-atomic-web-image-admission.zh.md @@ -0,0 +1,29 @@ +# Agent Note: Web 图片准入的原子性 + +Status: implemented + +[English](2026-07-29-atomic-web-image-admission.md) | 中文 + +## 问题 + +包含图片的提示词准入与 `session.selectModel` 都会在跨越异步模型查询与附件查询的过程中读取会话模态状态。如果没有统一的顺序边界,包含图片的提示词可能在支持图片的目标上通过校验,并发的选择操作却设置了纯文本目标;选择操作也可能在提示词已从 inbox 出队、但其持久消息事件尚未发布时漏掉该提示词。扫描不可变事件日志可以避免第二种竞态,但即使压缩(compaction)已经从当前模型历史中移除图片,仍会永久阻止选择纯文本目标。 + +## 决策 + +每个活跃 Web agent(智能体)都有一条私有 promise 链,由包含图片的提示词准入与模型选择共享。操作失败会照常传递给调用方,且不会使该链失效。纯文本提示词绕过该链,因为它们不会改变模态约束。 + +待发布集合会在排队条目出队时记录它,而 steering 条目在入队时即被记录(steering 条目从不进入排队 UI 镜像),并各自保留到匹配的 `user/message` 或 `steering/message` 事件发布。若准入结束时未发布事件,转为空闲状态会移除这些条目;inbox 丢弃会移除列出的工作项,会话 dispose(资源释放)则会移除所有剩余条目。模型选择会检查该集合、排队 UI 镜像以及 `Session.deriveMessages()`;后者表示压缩后模型当前可见的历史。 + +提供方适配器仍是最终的强制检查边界。宿主的顺序控制仅用于避免其可变路由与待发布图片状态在请求组装前彼此矛盾。 + +## 曾考虑的替代方案 + +**扫描每个不可变会话事件。** 这能捕获已发布的图片,但会把经压缩移除的内容视为永久对模型可见,从而阻止之后合法切换到纯文本路由。 + +**在 inbox 出队时退役待处理镜像。** 出队早于持久消息追加,因此恰好会留下一个时间区间,让模型选择既看不到待处理状态,也看不到已发布状态。 + +**序列化每个提示词和会话变更。** 纯文本提示词和无关的会话操作无法引入图片要求。更宽的锁会增加延迟与所有权复杂度,却不会再消除任何模态竞态。 + +## 后果 + +包含图片的提示词准入与并发模型选择之间具有确定的先后顺序,纯文本目标无法使已获准入但尚未发布的图片搁浅。模型选择可能等待正在进行的图片准入完成,而无关提示词仍按现有方式并发处理。当没有图片等待发布,且派生历史经过压缩后也不再含图片时,纯文本目标可以变得有效。 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml index 990e7e39bb..e7bdf260be 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.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/bug-fix/2026-07-31-same-basename-workspace-adoption.md -2026-07-31-same-basename-workspace-adoption.md: ed53804ea64df0d61db16e579c3d65af803dbb97 -2026-07-31-same-basename-workspace-adoption.zh.md: 82cfb7d90afca28f8e666742a758fda0202909f3 +2026-07-31-same-basename-workspace-adoption.md: 1192558632fdc8bd732ea59f0eca5051f49f5d2c +2026-07-31-same-basename-workspace-adoption.zh.md: 9c1f1ffd221936e24300b223ad395e1f44552810 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md index ed53804ea6..1192558632 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.md @@ -14,7 +14,7 @@ A Workspace is identified by its stable id and canonical directory path, while i The Host's `workspace.create({ path })` adoption route inherits that rule. The Workspace manager, picker, grouping tree, selection, rename, deletion, and Session creation continue to use `WorkspaceId`, so equal labels neither merge records nor redirect an operation. The sidebar hover card exposes each canonical path when the labels need disambiguation. -Explicit naming remains stricter. `workspace.create({ name })` and `workspace.rename` continue to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). +Explicit naming remains stricter. `workspace.rename` continues to reject a title already registered, as described by [manual Workspace naming](../feature/2026-07-25-session-list-browsing-and-manual-order.md). This prevents a user from deliberately introducing another ambiguous label while accepting collisions imposed by existing directory names. The path-adoption rule supersedes only the title-conflict clauses in the [Workspace product flow](../feature/2026-07-25-workspace-ui-product-flow.md) and [native directory picker](../feature/2026-07-27-native-workspace-directory-picker.md). The durable schema does not change: Workspace records already store id, path, and title independently, bootstrap can derive equal basenames, and startup validates duplicate paths rather than titles. @@ -30,7 +30,7 @@ Workspace registry and Host API tests create two real directories under differen **Use the full path as every Workspace title.** This removes the collision but makes the primary navigation label unnecessarily long. The full path remains available in the hover detail while the concise basename stays useful. -**Permit collisions from explicit rename and create-by-name operations too.** The registry supports that state, but those operations intentionally ask the user to choose a display name. Retaining their conflict response preserves the existing naming guard without blocking filesystem-selected paths. +**Permit collisions from the explicit rename operation too.** The registry supports that state, but rename intentionally asks the user to choose a display name. Retaining its conflict response preserves the existing naming guard without blocking filesystem-selected paths. ## Consequences diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md index 82cfb7d90a..9c1f1ffd22 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-07-31-same-basename-workspace-adoption.zh.md @@ -14,7 +14,7 @@ Workspace 的身份由其稳定 id 和规范目录路径确定,标题则是可 Host 的 `workspace.create({ path })` 接纳入口沿用该规则。Workspace 管理器、选择器、分组树、选择、重命名、删除和 Session 创建仍使用 `WorkspaceId`,因此相同标签既不会合并记录,也不会把操作指向其他记录。需要区分相同标签时,侧边栏悬停详情卡会显示各自的规范路径。 -显式命名仍采用更严格的规则。`workspace.create({ name })` 和 `workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 +显式命名仍采用更严格的规则。`workspace.rename` 仍会拒绝已注册的标题,具体见[手动 Workspace 命名](../feature/2026-07-25-session-list-browsing-and-manual-order.md)。这既防止用户主动引入另一个难以区分的标签,又允许既有目录名称造成的重名。路径接纳规则仅取代 [Workspace 产品流](../feature/2026-07-25-workspace-ui-product-flow.md)和[原生目录选择器](../feature/2026-07-27-native-workspace-directory-picker.md)中的标题冲突条款。 持久化 schema 未变:Workspace 记录本就分别存储 id、path 和 title,引导初始化可以派生出相同的 basename,启动校验检查的是重复路径而非重复标题。 @@ -30,7 +30,7 @@ Workspace 注册表与 Host API 测试会在不同父目录下创建两个末级 **将完整路径用作每个 Workspace 的标题。** 这会消除冲突,却使主导航标签不必要地过长。完整路径仍可在悬停详情中查看,而简洁的 basename 仍有价值。 -**也允许显式重命名和按名称创建操作产生重名。** 注册表支持这种状态,但这些操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 +**也允许显式重命名操作产生重名。** 注册表支持这种状态,但该操作本就是明确要求用户选择显示名称。保留冲突响应可维持现有命名防护,同时不阻止从文件系统选取的路径。 ## 后果 diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.i18n.yaml new file mode 100644 index 0000000000..bc88d6a6db --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.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-07-22-web-multimodal-image-input-and-durable-attachments.md +2026-07-22-web-multimodal-image-input-and-durable-attachments.md: 2a3925d8b779fed7f8b3330d079fdb3059685fd5 +2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md: be478ece73039201a820d804435610faea4cb3fe diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md new file mode 100644 index 0000000000..2a3925d8b7 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.md @@ -0,0 +1,214 @@ +# Agent Note: Web multimodal image input and durable attachments + +Status: implemented + +English | [中文](2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md) + +## Problem + +Before this change, the Web composer accepted only text: `InputBar` received a string draft, `ConversationService.send()` created text content, and the host forwarded that content to the agent. Users could not paste an image, inspect it before sending, submit an image-only prompt, or recover sent images from history. + +This is not only a composer gap. Core needs a durable image content block, providers need explicit modality handling, and the session log must reconstruct everything visible to a model. [The previous image-block removal](../../implemented/simplification/2026-07-04-drop-image-content-block.md) rejected a partial design that could silently lose or flatten images. A browser object URL, local path, provider URL, or base64 payload cannot be canonical session content. + +The [Web client architecture](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md) keeps components pure and per-session composer state in `ctx.conversation`; the [GUI layering and RPC protocol](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md) makes durable events the source of truth for both live rendering and history replay. Image intake, persistence, provider conversion, and rendering therefore need one explicit lifecycle. + +Peer products converge on an attachment rail above the editor, but their storage choices differ. Codex-style paths such as `/var/folders/.../codex-clipboard-*.png` are reasonable intake staging locations, not durable message identities: the operating system may delete them, another host cannot read them, and a resumed session cannot rely on them. + +## Decision + +Pasted or dropped raster images are the Web composer's first consumer of a durable attachment capability. Unsent files remain temporary client-owned draft state. The host validates and durably commits every accepted user image before appending its message event. A provider adapter that produces structured image output must durably commit the output before appending its assistant block. Canonical user and assistant content contains only role-neutral `ImageBlock` references. + +Version one supports PNG, JPEG, WebP, and GIF paste and drag-and-drop, image-only or mixed prompts, historical user and assistant image rendering, and original-image preview on double-click. File picking, generic files, PDF, audio, video, image copying, and a custom context menu remain separate follow-ups. + +### Product behavior + +- Pasting or dropping one or more supported images adds ordered thumbnails above the textarea without inserting placeholder text. Dragging files over the composer highlights the drop target. +- The same resident `InputBar` renders the rail in both blank-session Hero and active-session layouts. The rail is hidden when empty and scrolls horizontally instead of widening the composer. +- Each approximately 72-by-72-pixel thumbnail has a remove action and opens its original draft image on double-click. +- A prompt may contain text and images or images only. Pure text paste remains native browser behavior; mixed clipboard content inserts its text normally while adding its files to the rail, and file-only paste prevents default browser handling. File drops on the composer always prevent browser navigation and report unsupported files locally. +- A failed send restores the complete text and image draft without clobbering text or images added while the request was in flight. Removal, successful send, session-scope disposal, rendered-history disposal, and application disposal revoke the object URLs they own. +- Historical user and assistant images use one `MessageImage` control. Inline images preserve intrinsic aspect ratio, do not upscale, and stay within a 240-by-240-pixel box. +- Double-clicking a message image opens the stored original in a viewport-bounded modal. Escape, the close control, and backdrop activation close it and restore focus. +- Version one does not override the browser context menu and provides no explicit image-copy action. + +### Storage lifecycle and ownership + +The persistence boundary is message acceptance, not paste: + +| State | Allowed representation | Durability and ordering | +| --- | --- | --- | +| Unsent user draft | Browser `File` plus object URL; a native client may use an OS temporary file such as `/var/...` | Temporary and client-owned. It may disappear on reload or process exit and never appears in a session event. | +| Accepted user image | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The host commits every image before `agent.send()` or `agent.steer()` can append the owning user event. | +| Structured model image output | Immutable object below `DSH_HOME` plus `ImageAttachmentRef` | The provider adapter commits the bytes before it emits a completed image block or assistant message event. Temporary URLs, paths, and base64 are forbidden in the event. | + +Each session's `InputMachine` state keeps the ordered runtime-only attachment identifiers alongside the live draft. The framework-owned chat store receives only the draft's plain-text persistence mirror, while `ConversationService` owns the corresponding browser-only `File` and object-URL registry: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> + +interface ChatStoreState { + selection: object | null + draft: string + view: string | null +} + +interface InputState { + draft: string + imageIds: readonly DraftAttachmentId[] +} + +interface ComposerAttachment { + kind: 'image' + id: DraftAttachmentId + file: File + previewUrl: string +} +``` + +This split uses the session provide channel's input hook and actions as the single subscription path for live composer state while keeping non-serializable browser objects out of persisted JSON. Only the plain-text draft mirror uses `localStorage`; attachment identifiers, browser `File` objects, and object URLs remain scoped to the live session input shell. Unsent images therefore do not survive reload or session-scope disposal. A Workspace switch moves a mixed text-and-image draft only when the destination shell accepts the complete image batch; refusal leaves both parts with the source. A native client may stage input in an OS temporary directory, but it must treat that path exactly like the browser object URL: delete it when no longer needed and copy the bytes into the durable store before message acceptance. + +The local attachment backend resolves an explicit `dshHome`, then `$DSH_HOME`, then `~/.dsh`. It stores content-addressed objects below `$DSH_HOME/attachments/v1/objects//` with owner-only directory and file permissions. On each process's first save for one home, it creates that home and synchronizes every ancestor entry to the filesystem root; existence is not treated as durability because another process may still be between `mkdir` and parent `fsync`. A temporary file is then written, synchronized, atomically published, and made durable with directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) before the service returns a reference. The content digest is encoded in the opaque `sha256:` identifier. Admission and reads fully decode supported rasters before accepting their format and dimensions, and every read also verifies the digest, byte length, and logged metadata. + +The store performs no automatic deletion in version one. Sent user images and model-generated images remain reachable for history, resume, and fork. Reference-aware garbage collection needs a separate design because an age-only rule can delete data still referenced by a durable session. Deployment byte and pixel limits are admission policy on writes; reads verify the digest and recorded metadata without reapplying current admission limits, so lowering policy does not invalidate older history. + +### Durable content and prompt wire + +The attachment seam exposes immutable image write and verified read operations. The canonical metadata is deliberately narrower than a generic file record: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`ImageBlock` joins the merge-extensible core `ContentBlockMap` and is valid in either user or assistant content. It never carries base64, an object URL, a filesystem path, or a provider-owned locator. This keeps the session event plus immutable object store sufficient to reconstruct the exact model-visible image. The LLM vocabulary therefore has a type-only dependency on the attachment seam; provider runtime dependencies remain adapter-specific. + +The browser cannot mint a durable reference, so `session.prompt` accepts a narrow intake union rather than canonical `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +Base64 crosses JSON-RPC once and is discarded after persistence. The host validates canonical base64, image count, aggregate bytes, individual bytes, the declared MIME against a fully decoded raster, intrinsic dimensions, and decoded-pixel count. It awaits the seam's storage-free `validateImage` for every batch member before saving any member, so one malformed image cannot strand the batch's valid members as unreferenced objects. Storage commits then run in submission order to bound full-raster decoder memory. If a later storage I/O operation fails, the host appends no user event, but an earlier immutable content-addressed object may remain unreferenced; version one leaves cleanup to future reference-aware garbage collection instead of adding destructive rollback to the deduplicated store. Only after every image succeeds does it call the agent with normalized text and durable image blocks in the submitted order. A failure exposes no attachment path or raw bytes. + +`session.attachment` is a read-only, session-scoped endpoint. The host serves bytes only when a durable event in that session references the requested attachment identifier. The client deduplicates loads by session and attachment identifier while that session is rendered, revokes resolved URLs on rendered-session disposal, and rejects invalidated late loads before allocating an object URL so an unmounted session or disposed service cannot repopulate the cache. + +### Model capabilities and provider behavior + +Model catalog entries gain optional merge-extensible input modality declarations. A missing declaration means unknown; a present list without `image` is an explicit negative capability. + +The host is the authoritative preflight boundary. It resolves the session's latest routed provider/model, falling back through agent options to host defaults; if that model explicitly excludes image input, it rejects the prompt before writing any attachment or event, and the client restores the draft. Image-bearing prompt admission and model selection share one per-agent serial boundary, and a dequeued prompt remains pending until its durable message event publishes ([ordering decision](../bug-fix/2026-07-29-atomic-web-image-admission.md)); a steering carrier gates from its enqueue until its `steering/message` event publishes, closing the outbox hop that never enters the queued mirror. Selection rejects a text-only target while an image is pending publication or remains in the session's current derived history. Compaction can remove old images and make a later text-only selection valid; idle without publication releases a claimed queued carrier, while steering retained in the outbox stays gated until publication or discard. `session.updateQueue` edits accept text content only, so a queue edit cannot inject an image past this admission boundary. Unknown capability proceeds to the adapter guard so uncatalogued model identifiers remain usable. The browser rejects unsupported declared image media types before allocating preview URLs, but it does not snapshot deployment limits or model capability: a handshake snapshot cannot represent a session's current target after `session.selectModel`, and deployment policy may change independently. The host validates the complete batch against current byte, count, aggregate, media, dimension, pixel, and routed-model policy before writing any attachment or event; its rejection renders through the composer error strip. + +The Pi-AI adapter is the first visual-input route: it resolves `ctx.attachments` at request time, recursively converts each durable image reference including references nested inside tool results, and emits native image content only for models that declare image input. The shipped composition registers Pi-AI OpenAI and Anthropic routes alongside the text-only default DeepSeek route; selecting the active provider/model remains a host composition or profile concern rather than an image-input CLI feature. Request-time service resolution keeps Cordis load order from freezing optional attachment availability. The hand-written DeepSeek adapter throws typed `UNSUPPORTED_CONTENT` for an image anywhere in the request, including nested tool results. No adapter may flatten or skip an image. + +Core supports structured assistant image blocks, but no current production provider route is certified for image output. Any future output-capable adapter must retrieve provider bytes under bounded size and time policy, validate them through the same attachment service, persist them, and only then publish the atomic `ImageBlock`. A URL in assistant Markdown remains text and is never downloaded automatically. + +Provider-neutral token estimation does not guess visual pricing from image dimensions; provider-reported usage remains authoritative. ACP renders an explicit image marker until that protocol surface gains native image support rather than silently omitting the block. + +Compaction replays the selected conversation prefix, including image references, into the configured summarization route. A visual-capable route resolves those references through its adapter; a text-only route fails explicitly instead of silently dropping the visual context. The synthesized checkpoint remains text-only, and `compact-basic` rejects image summary output with `UNSUPPORTED_CONTENT`. + +### History rendering and original preview + +History folding preserves `ImageBlock` in both user and assistant messages. User images align to the trailing edge above their text; assistant images remain in their original content-block position in the leading narration flow. `MessageImage` derives a stable inline box from recorded dimensions, resolves bytes through the session-authorized loader, uses `object-fit: contain`, and turns a missing or corrupt object into a retryable error control. + +Composer thumbnails and each `MessageImage` own ephemeral original-preview state and invoke the same pure `ImageLightbox`. The modal uses the already resolved original object URL, constrains only display size, focuses its close control, and restores the previous focus target when closed. + +### Limits and trust boundaries + +Version one accepts PNG, JPEG, WebP, and GIF only. SVG and remote URLs are excluded. Default limits are 5 MiB per image, 10 images and 20 MiB aggregate image bytes per message, and 40 million intrinsic pixels per image. These deployment-varying limits are validated backend configuration and enforced by the host before persistence. The client connection carrier has an independent configurable `maxRequestBodyBytes` cap (32 MiB by default) for every API request and fails load if it cannot hold the attachment service's aggregate image limit after base64 and envelope expansion; lowering image policy therefore never silently lowers the carrier limit for valid text or other RPCs. A body without a declared length is rejected the moment it crosses the cap rather than drained to its end. + +Malformed base64, unsupported or mismatched media, truncated image payloads, excess bytes, excess image count, excess pixels, missing objects, and integrity mismatches return stable structured failures. Original filenames are reduced to a display basename, control characters are removed, and no local path is logged or returned to the browser. + +### Package and surface changes + +| Surface | Responsibility | +| --- | --- | +| `packages/attachment/attachment` | Opaque attachment identifier, image reference, limits, failures, and `ctx.attachments` service. | +| `packages/attachment/attachment-local` | Private content-addressed storage, complete raster decoding, integrity verification, and configuration. | +| `packages/llm/llm` | Role-neutral `ImageBlock` and input-modality metadata. | +| `packages/llm/llm-pi-ai` | Resolve durable supported image input into native provider content. | +| `packages/llm/llm-deepseek` | Reject image content explicitly. | +| `packages/compact/compact-basic` | Preserve images in summary input and reject non-text checkpoint output explicitly. | +| `packages/host/apiproxy` and `packages/bundle/base` | Narrow upload wire, persist-before-event ordering, session-authorized reads, limits and model preflight, plus default profile composition. | +| `packages/client/connection` and `packages/client/runtime` | Bounded request buffering, wire types, fixture images, prompt uploads, attachment reads, and durable-reference folding. | +| `packages/client/ui-conversation` | Per-session draft images, attachment rail, user and assistant image controls, and original preview. | +| `packages/acp/acp` | Explicit fallback rendering for image blocks. | + +The attachment packages form the interface/implementation side of one capability seam. Composer behavior stays in the conversation object layer, provider conversion stays in adapters, and no change is required in `agent-loop`. + +### Implementation + +The implemented slice includes the attachment seam, role-neutral image block, Pi-AI input conversion, DeepSeek rejection, durable host ordering, Web upload/read protocol, current image-limit enforcement, bounded Web request bodies, in-memory draft images, paste/drop rail, user and assistant history rendering, double-click preview, compaction handling, and keyless assembled Web coverage. + +No compatibility shim is required for the pre-release prompt wire; all call sites and fixtures change with the introducing slice. + +## Alternatives considered + +### Keep every intake image in `/var` or another temporary directory + +Temporary storage is appropriate before send, including for a native client that receives clipboard files through the operating system. It is not appropriate after acceptance: cleanup is outside the harness's control, paths are host-specific, and resume or fork can outlive the file. The proposal permits temporary staging but copies accepted bytes into `DSH_HOME` before the event. + +### Persist immediately on paste or drop + +Immediate persistence makes drafts reload-resistant but creates durable objects before a session or message owns them, which requires quota, orphan lifetime, and cleanup policy. Version one keeps the unsent draft temporary and makes send acceptance the durability boundary. + +### Inline base64 in messages and session logs + +This duplicates binary data across RPC, events, history pages, forks, compaction, and browser storage, and invites token accounting to treat encoding text as model text. One immutable object plus small references keeps the durable representation bounded. + +### Use browser object URLs, local paths, or provider URLs as canonical content + +Object URLs expire with the document, local paths are not portable, and provider URLs may expire, track viewers, or expose credentials. They remain temporary transport or preview details only. + +### Use one generic `AttachmentBlock` for images, files, audio, and video + +Composer presentation can use a generic attachment rail, but provider semantics are modality-specific. Images are native multimodal input; PDFs may be provider files or extracted text; video may be native, sampled, or unsupported. A specific `ImageBlock` forces every consumer to handle or reject the modality explicitly. + +### Rely on UI capability checks or silently filter images + +UI state can be stale and does not protect direct SDK, ACP, replay, or uncatalogued model paths. Silent filtering changes user intent. Provider enforcement remains mandatory, while UI checks are optional earlier feedback. + +## Testing + +- Storage tests cover content-addressed deduplication, private permissions, admission failures, corruption/missing-object failures, and reading history after deployment limits are lowered. +- Host and protocol tests cover persist-before-event ordering, absence of base64 in logs, session-scoped authorization, capability rejection, upload limits, bounded HTTP request bodies, image-admission/model-selection races (queued and steering placements), pending publication, idle release without publication, text-only queue edits, and selection against current derived history after compaction. +- Client unit tests cover paste and drop, mixed clipboard text, image-only send, draft restoration, ordering, draft/session-scope/application object-URL cleanup, and a deferred historical read that completes after disposal; the keyless assembled built-client lane (`apps/web/tests/image-display.snapshot.ts`, `DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`) covers the historical user and assistant galleries over the authorized attachment route, the original-size lightbox, and the composer paste rail. +- Adapter and compaction tests cover native Pi-AI image conversion, late attachment-service composition, text-only rejection, recursively nested tool-result images, preserved summary input, and explicit image-output rejection. +- A credentialed real-API test sends a PNG through the Anthropic `claude-opus-4-8` route and requires the model to identify its QR code. +- The current production adapter set has no certified image-output route; output-provider certification remains outside version one. + +## Consequences + +- Durable storage grows without garbage collection. Version one chooses replay safety over premature deletion. +- A missing or corrupt object makes exact model reconstruction fail. Failing loud preserves integrity but may prevent that session from continuing until repaired. +- JSON-RPC base64 adds upload memory and roughly one-third encoding overhead. Version-one limits bound it; larger media needs streaming or a binary transport. +- Unsent images do not survive reload. Durable drafts need quota and orphan cleanup rather than reusing message storage implicitly. +- Original preview decodes more pixels than the inline control displays. Pixel limits, one clicked preview, and object-URL disposal bound but do not eliminate transient browser memory. +- Capability metadata may be missing or stale. Host preflight improves feedback, while adapter enforcement remains authoritative. +- A future output provider may require authenticated retrieval before an assistant image can complete, adding latency and a new failure point. Persist-before-event ordering favors replay integrity. +- File picking, generic files/PDF, audio/video, durable draft staging, image copying, custom context menus, output-provider certification, and reference-aware garbage collection remain independent designs. diff --git a/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md new file mode 100644 index 0000000000..be478ece73 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-22-web-multimodal-image-input-and-durable-attachments.zh.md @@ -0,0 +1,214 @@ +# Agent Note: Web 多模态图片输入与持久附件 + +Status: implemented + +[English](2026-07-22-web-multimodal-image-input-and-durable-attachments.md) | 中文 + +## 问题 + +在此变更之前,Web 输入区仅接受文本:`InputBar` 接收字符串草稿,`ConversationService.send()` 创建文本内容,宿主再把该内容转发给 agent(智能体)。用户无法粘贴图片、在发送前查看图片、提交仅含图片的提示词,也无法从历史记录中恢复已发送图片。 + +这不只是输入区功能缺失。核心层需要持久图片内容块,提供方需要明确处理模态,会话日志则必须重建模型可见的全部内容。[此前移除图片块的决策](../../implemented/simplification/2026-07-04-drop-image-content-block.md)否决了可能静默丢失图片或将其展平的不完整设计。浏览器对象 URL、本地路径、提供方 URL 或 base64 数据都不能成为规范会话内容。 + +[Web 客户端架构](../../implemented/architecture/2026-07-19-gui-web-client-architecture.md)要求组件保持纯粹,并将每个会话的输入区状态放在 `ctx.conversation` 中;[GUI 分层与 RPC 协议](../../implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)则要求持久事件成为实时渲染与历史回放的共同真源。因此,图片接收、持久化、提供方转换和渲染需要遵循同一个明确的生命周期。 + +同类产品普遍在编辑器上方设置附件栏,但存储方案各不相同。诸如 `/var/folders/.../codex-clipboard-*.png` 的 Codex 式路径适合作为接收输入时的暂存位置,却不能作为持久消息身份:操作系统可能删除文件,另一台宿主无法读取文件,恢复后的会话也不能依赖文件仍然存在。 + +## 决策 + +粘贴或拖放的光栅图片是 Web 输入区对持久附件能力的首个应用场景。未发送文件仍是由客户端持有的临时草稿状态。宿主在追加相应消息事件前,校验并持久提交每张已接受的用户图片。生成结构化图片输出的提供方适配器在追加相应助手块前,也必须持久提交输出。规范用户内容与助手内容只包含角色无关的 `ImageBlock` 引用。 + +第一版支持粘贴和拖放 PNG、JPEG、WebP 与 GIF,支持仅图片或混合提示词,支持渲染历史用户图片与助手图片,并支持双击预览原图。文件选择、通用文件、PDF、音频、视频、图片复制和自定义上下文菜单仍分别作为后续工作。 + +### 产品行为 + +- 粘贴或拖放一张或多张受支持的图片后,文本框上方会按顺序显示缩略图,但不会插入占位文本。文件拖入输入区时会高亮放置目标。 +- 同一个常驻 `InputBar` 会在空白会话 Hero 和活跃会话布局中渲染附件栏。附件栏为空时隐藏,通过横向滚动避免撑宽输入区。 +- 每个缩略图约为 72 × 72 像素,带有移除操作;双击时打开草稿原图。 +- 提示词可同时包含文本与图片,也可仅包含图片。粘贴纯文本时保持浏览器原生行为;粘贴混合的剪贴板内容时,文本会正常插入,文件则同时添加到附件栏;仅粘贴文件时才阻止浏览器的默认处理。在输入区放置文件时总会阻止浏览器导航,并在本地报告不受支持的文件。 +- 发送失败时恢复完整的文本与图片草稿,但不会覆盖请求飞行期间新增的文本或图片。移除、发送成功、会话 scope 释放、已渲染历史记录释放和应用释放都会撤销各自持有的对象 URL。 +- 历史用户图片与助手图片共用一个 `MessageImage` 控件。行内图片保持固有宽高比、不放大,并限制在 240 × 240 像素的边界框内。 +- 双击消息图片会在不超出视口的模态框中打开存储的原图。按 Escape、激活关闭控件或激活背景区域都会关闭模态框并恢复焦点。 +- 第一版不覆盖浏览器上下文菜单,也不提供明确的图片复制操作。 + +### 存储生命周期与归属 + +持久化边界是消息被接受,而不是图片被粘贴: + +| 状态 | 允许的表示 | 持久性与顺序 | +| --- | --- | --- | +| 未发送的用户草稿 | 浏览器 `File` 加对象 URL;原生客户端可以使用 `/var/...` 等操作系统临时文件 | 临时且由客户端持有。它可能在重载或进程退出后消失,绝不出现在会话事件中。 | +| 已接受的用户图片 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 在 `agent.send()` 或 `agent.steer()` 能够追加所属用户事件前,宿主提交每张图片。 | +| 结构化模型图片输出 | `DSH_HOME` 下的不可变对象加 `ImageAttachmentRef` | 提供方适配器在发出已完成的图片块或助手消息事件前提交字节。事件中禁止出现临时 URL、路径和 base64。 | + +每个会话的 `InputMachine` 状态在实时草稿旁保存仅限运行时的有序附件标识符。框架持有的 chat store 只接收草稿的纯文本持久化镜像,`ConversationService` 则持有相应的浏览器专用 `File` 与对象 URL 注册表: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type DraftAttachmentId = Branded<'DraftAttachmentId'> + +interface ChatStoreState { + selection: object | null + draft: string + view: string | null +} + +interface InputState { + draft: string + imageIds: readonly DraftAttachmentId[] +} + +interface ComposerAttachment { + kind: 'image' + id: DraftAttachmentId + file: File + previewUrl: string +} +``` + +这一拆分把会话 provide 通道的输入 hook 与 actions 用作实时输入区状态的唯一订阅路径,同时避免把不可序列化的浏览器对象写进持久 JSON。只有纯文本草稿镜像使用 `localStorage`;附件标识符、浏览器 `File` 对象和对象 URL 都限定在实时会话输入外壳的 scope 内。未发送图片因此无法跨重载或会话 scope 释放保留。切换 Workspace 时,只有目标外壳接受完整图片批次,图文混合草稿才会移动;拒绝时,文本和图片都留在来源外壳。原生客户端可以在操作系统临时目录中暂存输入,但必须像对待浏览器对象 URL 一样对待该路径:不再需要时删除,并在消息被接受前把字节复制进持久存储。 + +本地附件后端依次解析显式 `dshHome`、`$DSH_HOME` 和 `~/.dsh`。它把内容寻址对象存储在 `$DSH_HOME/attachments/v1/objects//` 下,并为目录和文件设置仅所有者可访问的权限。每个进程首次为某个 home 保存对象时,都会创建该 home,并逐级同步每个祖先目录项直至文件系统根目录;不能把存在视为持久性,因为另一个进程可能仍处于 `mkdir` 与父目录 `fsync` 之间。随后,服务写入并同步临时文件,再以原子方式发布,并对发布路径执行目录同步使其持久(POSIX;Windows 依赖文件系统元数据日志),之后才返回引用。内容摘要编码在不透明的 `sha256:` 标识符中。写入准入与读取都会完整解码受支持的光栅图片,之后才接受其格式和尺寸;每次读取还会校验摘要、字节长度和已记录的元数据。 + +第一版不对存储执行自动删除。已发送的用户图片和模型生成图片会一直保留,以供历史记录、恢复和 fork 使用。按引用感知的垃圾回收需要单独设计,因为仅按时间清理可能删除仍被持久会话引用的数据。部署的字节和像素限制是写入时的准入策略;读取时会校验摘要和已记录的元数据,但不重新应用当前准入限制,因此收紧策略不会导致旧历史记录失效。 + +### 持久内容与提示词协议 + +附件服务边界公开不可变图片写入和经过校验的读取操作。规范元数据刻意比通用文件记录更窄: + +```ts +import type { Branded } from '@deepseek-ai/dsh-brand' + +type AttachmentId = Branded<'AttachmentId'> + +interface ImageAttachmentRef { + attachmentId: AttachmentId + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + bytes: number + width: number + height: number + name?: string +} + +interface ImageBlock { + type: 'image' + attachment: ImageAttachmentRef +} +``` + +`ImageBlock` 加入可合并扩展的核心 `ContentBlockMap`,在用户内容和助手内容中都有效。它绝不携带 base64、对象 URL、文件系统路径或提供方持有的定位符。因此,会话事件与不可变对象存储足以共同重建模型可见的确切图片。LLM 词汇由此仅在类型层面依赖附件服务边界;提供方运行时依赖仍由各适配器持有。 + +浏览器无法生成持久引用,因此 `session.prompt` 接受范围狭窄的接收联合类型,而不是规范 `ContentBlock[]`: + +```ts +export {} + +type PromptInputPart = + | { type: 'text'; text: string } + | { + type: 'image' + mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + data: string + name?: string + } +``` + +Base64 只跨越一次 JSON-RPC,并在持久化后丢弃。宿主会校验规范 base64、图片数量、总字节数、单张图片字节数、声明的 MIME 与完整解码后的光栅图片是否一致、固有尺寸和解码像素数。它会在保存任何成员之前,等待服务边界上不触碰存储的 `validateImage` 完成对每个批次成员的校验,因此一张畸形图片不会把批次中的有效成员留成无引用对象。随后按提交顺序执行存储提交,以限制完整光栅解码器的内存占用。如果后续存储 I/O 操作失败,宿主不会追加用户事件,但先前的不可变内容寻址对象可能保持无引用状态;第一版将清理留给未来按引用感知的垃圾回收,而不向去重存储添加破坏性回滚。只有每张图片都成功后,宿主才会用规范化文本和按提交顺序排列的持久图片块调用 agent。失败时不公开任何附件路径或原始字节。 + +`session.attachment` 是只读且限定于会话作用域的端点。只有该会话中的持久事件引用了所请求的附件标识符,宿主才提供字节。会话处于渲染状态时,客户端会按会话和附件标识符对加载操作去重;已渲染会话释放时会撤销已解析的 URL,并在分配对象 URL 前拒绝已失效的延迟加载,以免已卸载的会话或已释放的服务重新写入缓存。 + +### 模型能力与提供方行为 + +模型目录项增加可选且可合并扩展的输入模态声明。缺少声明表示未知;声明存在但不含 `image`,则明确表示不支持图片。 + +宿主是权威的前置检查边界。它会解析会话最新路由到的提供方和模型,并在缺失时依次回退到 agent 选项和宿主默认值;如果该模型明确排除图片输入,宿主会在写入任何附件或事件前拒绝提示词,客户端则恢复草稿。包含图片的提示词准入与模型选择共用一个逐 agent 的串行边界,而且已经出队的提示词在其持久消息事件发布前仍保持待发布状态([顺序决策](../bug-fix/2026-07-29-atomic-web-image-admission.md));steering 载体则从入队起就参与门槛,直到其 `steering/message` 事件发布为止,堵住了从不进入排队镜像的 outbox 窗口。当图片正等待发布或仍存在于会话当前的派生历史中时,模型选择会拒绝纯文本目标。压缩(compaction)可以移除旧图片,使之后选择纯文本目标变得有效;未发布任何事件即转入空闲时,已认领的 queued 载体会被释放,而保留在 outbox 中的 steering 在发布或丢弃前始终受门槛约束。`session.updateQueue` 的编辑只接受文本内容,因此队列编辑无法绕过该准入边界注入图片。能力未知时继续进入适配器强制检查,使未收录的模型标识符仍然可用。浏览器会在分配预览 URL 前拒绝声明不支持的图片媒体类型,但不会为部署限制或模型能力保留快照:握手快照无法表达 `session.selectModel` 之后会话的当前目标,部署策略也可能独立变化。宿主会根据当前的单张字节数、图片数量、总字节数、媒体类型、尺寸、像素数和路由模型策略校验整个批次,再写入任何附件或事件;其拒绝通过 composer 错误条呈现。 + +Pi-AI 适配器是首条视觉输入路径:它在请求时解析 `ctx.attachments`,递归转换每个持久图片引用,包括嵌套在工具结果中的引用,并且仅为声明支持图片输入的模型生成提供方原生图片内容。交付的组合会同时注册 Pi-AI OpenAI、Anthropic 路由和仅支持文本的默认 DeepSeek 路由;选择当前提供方/模型仍由宿主组合或配置承担,而不是图片输入 CLI(命令行界面)的功能。在请求时解析服务,可避免 Cordis 加载顺序将可选附件服务的可用性固化。手写 DeepSeek 适配器遇到请求中任何位置的图片时都会抛出类型化的 `UNSUPPORTED_CONTENT` 错误,包括嵌套工具结果中的图片。任何适配器都不得将图片展平或跳过。 + +核心层支持结构化助手图片块,但当前没有任何生产提供方路径通过图片输出认证。未来任何支持输出的适配器都必须在有界的大小和时间策略下获取提供方字节,通过同一个附件服务校验并持久化字节,之后才能以原子方式发布 `ImageBlock`。助手 Markdown 中的 URL 仍是文本,绝不自动下载。 + +提供方无关的 token 估算不会根据图片尺寸猜测视觉定价;提供方返回的用量仍是权威值。在 ACP(Agent Client Protocol)接口原生支持图片前,ACP 会渲染明确的图片标记,而不是静默省略该块。 + +压缩会把选定的会话前缀(包含图片引用)回放到已配置的摘要生成路径中。支持视觉的路径会通过适配器解析这些引用;仅文本路径会明确失败,而不是静默丢弃视觉上下文。合成的检查点仍仅包含文本,`compact-basic` 会以 `UNSUPPORTED_CONTENT` 拒绝包含图片的摘要输出。 + +### 历史渲染与原图预览 + +历史记录折叠会在用户消息和助手消息中保留 `ImageBlock`。用户图片在文本上方靠尾端对齐;助手图片则保留在靠前端叙述流中的原内容块位置。`MessageImage` 根据记录的尺寸派生稳定的行内边界框,通过会话授权加载器解析字节,使用 `object-fit: contain`,并将对象缺失或损坏转换为可重试的错误控件。 + +输入区缩略图和每个 `MessageImage` 各自持有临时原图预览状态,并调用同一个纯 `ImageLightbox`。模态框使用已经解析的原始对象 URL,只限制显示尺寸;它会聚焦关闭控件,并在关闭时把焦点恢复到先前的目标。 + +### 限制与信任边界 + +第一版仅接受 PNG、JPEG、WebP 和 GIF。不接受 SVG 和远程 URL。默认限制为每张图片 5 MiB、每条消息 10 张图片和 20 MiB 图片总字节数,以及每张图片 4,000 万个固有像素。这些随部署变化的限制属于经过校验的后端配置,并由宿主在持久化前强制执行。客户端连接载体为每个 API 请求设置独立且可配置的 `maxRequestBodyBytes` 上限(默认 32 MiB);如果该上限无法容纳附件服务的图片总量限制经 base64 和请求封装膨胀后的大小,加载就会失败。因此,降低图片策略绝不会静默降低有效文本或其他 RPC 的载体上限。未声明长度的请求体在越过上限的瞬间即被拒绝,而不是先读完再拒。 + +格式错误的 base64、不支持或不匹配的媒体、截断的图片数据、超出字节限制、超出图片数量、超出像素限制、对象缺失和完整性不匹配都会返回稳定的结构化错误。原始文件名只保留用于显示的末段,控制字符会被移除,并且任何本地路径都不会写入日志或返回浏览器。 + +### 包与接口变更 + +| 接口 | 职责 | +| --- | --- | +| `packages/attachment/attachment` | 不透明附件标识符、图片引用、限制、错误和 `ctx.attachments` 服务。 | +| `packages/attachment/attachment-local` | 私有内容寻址存储、完整光栅解码、完整性校验和配置。 | +| `packages/llm/llm` | 角色无关的 `ImageBlock` 和输入模态元数据。 | +| `packages/llm/llm-pi-ai` | 将持久且受支持的图片输入解析为提供方原生内容。 | +| `packages/llm/llm-deepseek` | 明确拒绝图片内容。 | +| `packages/compact/compact-basic` | 在摘要输入中保留图片,并明确拒绝非文本检查点输出。 | +| `packages/host/apiproxy` 和 `packages/bundle/base` | 范围狭窄的上传协议、先持久化再追加事件的顺序、会话授权读取、限制和模型前置检查,以及默认 profile 组合。 | +| `packages/client/connection` 和 `packages/client/runtime` | 有界请求缓冲、协议类型、fixture(测试前置数据)图片、提示词上传、附件读取和持久引用折叠。 | +| `packages/client/ui-conversation` | 每个会话的草稿图片、附件栏、用户与助手图片控件和原图预览。 | +| `packages/acp/acp` | 图片块的明确兜底渲染。 | + +附件包(package)构成一个能力服务边界的接口与实现侧。输入区行为留在会话对象层,提供方转换留在适配器中,无需修改 `agent-loop`。 + +### 实现 + +已实现的范围包括附件服务边界、角色无关的图片块、Pi-AI 输入转换、DeepSeek 拒绝、宿主持久化顺序、Web 上传与读取协议、当前图片限制执行、大小受限的 Web 请求体、内存草稿图片、粘贴与拖放附件栏、用户与助手历史图片渲染、双击预览、压缩处理,以及组装后无需密钥的 Web 覆盖。 + +预发布提示词协议不需要兼容包装层;引入相应切片时会同时修改所有调用点和 fixture。 + +## 曾考虑的替代方案 + +### 将每张粘贴图片保留在 `/var` 或其他临时目录中 + +临时存储适合在发送前使用,也适合通过操作系统接收剪贴板文件的原生客户端。但它不适合在消息被接受后继续使用:清理不在 harness 的控制范围内,路径因宿主而异,恢复或 fork 后的会话也可能比文件存在得更久。提案允许临时暂存,但会在追加事件前将已接受的字节复制进 `DSH_HOME`。 + +### 粘贴或拖放后立即持久化 + +立即持久化可以让草稿在重载后继续存在,但会在会话或消息持有对象前就创建持久对象,因此必须定义配额、遗留对象生命周期和清理策略。第一版保持未发送草稿为临时状态,并把发送被接受作为持久性边界。 + +### 在消息与会话日志中内联 base64 + +这种方式会在 RPC、事件、历史分页、fork、压缩和浏览器存储中复制二进制数据,还会诱使 token 计量把编码文本当成模型文本。单一不可变对象配合小型引用,可以让持久表示保持有界。 + +### 使用浏览器对象 URL、本地路径或提供方 URL 作为规范内容 + +对象 URL 会随文档失效,本地路径不可移植,提供方 URL 则可能过期、跟踪查看者或暴露凭据。它们只能作为临时传输或预览细节存在。 + +### 用一个通用 `AttachmentBlock` 表示图片、文件、音频和视频 + +输入区展示可以使用通用附件栏,但提供方语义取决于具体模态。图片是原生多模态输入;PDF 可能是提供方文件或提取后的文本;视频可能由模型原生支持、抽帧处理或不受支持。特定的 `ImageBlock` 会迫使每个消费方明确处理或拒绝该模态。 + +### 依赖 UI 能力检查或静默过滤图片 + +UI 状态可能陈旧,也无法保护直接 SDK、ACP、回放或未收录模型的路径。静默过滤会改变用户意图。提供方强制检查仍是必需项,UI 检查则是可选的提前反馈。 + +## 测试 + +- 存储测试覆盖内容寻址去重、私有权限、准入失败、对象损坏或缺失时的失败,以及收紧部署限制后读取历史数据。 +- 宿主与协议测试覆盖先持久化再追加事件的顺序、日志中不含 base64、会话作用域授权、能力拒绝、上传限制、大小受限的 HTTP 请求体、图片准入与模型选择的竞态(排队与 steering 两种放置)、待发布状态、未发布即空闲时的门槛释放、仅文本的队列编辑,以及压缩后依据当前派生历史进行的选择。 +- 客户端单元测试覆盖粘贴与拖放、混合剪贴板文本、仅图片发送、草稿恢复、顺序、草稿、会话作用域和应用层级的对象 URL 清理,以及一项在释放后才完成的延迟历史读取;keyless 的组装后构建产物通道(`apps/web/tests/image-display.snapshot.ts`,`DSH_EXAMPLE_MODE=lib pnpm run test:snapshot`)覆盖经授权附件路由渲染的历史用户与助手图片画廊、原图 lightbox,以及 composer 粘贴缩略图条。 +- 适配器与压缩测试覆盖 Pi-AI 原生图片转换、后置附件服务组合、仅文本拒绝、递归嵌套在工具结果中的图片、保留摘要输入,以及明确拒绝图片输出。 +- 需要凭据的实际 API 测试会通过 Anthropic `claude-opus-4-8` 路径发送一张 PNG,并要求模型识别其中的二维码。 +- 当前生产适配器集合没有经过认证的图片输出路由;输出提供方认证仍不在第一版范围内。 + +## 后果 + +- 持久存储会在没有垃圾回收时持续增长。第一版选择回放安全,而不是过早删除。 +- 对象缺失或损坏会让模型请求无法精确重建。明确失败可以保持完整性,但在修复前可能阻止该会话继续运行。 +- JSON-RPC base64 会增加上传内存,并带来约三分之一的编码开销。第一版的限制可以约束开销;更大的媒体需要流式传输或二进制传输协议。 +- 未发送图片无法跨重载保留。持久草稿需要配额和遗留对象清理,而不是隐式复用消息存储。 +- 原图预览解码的像素多于行内控件显示的像素。像素限制、一次只打开一个预览和对象 URL 释放可以约束但无法消除浏览器瞬时内存占用。 +- 能力元数据可能缺失或陈旧。宿主前置检查可以改善反馈,适配器强制检查仍是权威结果。 +- 未来输出提供方可能需要经过身份认证的下载,助手图片才能完成,这会增加延迟与新的故障点。先持久化再追加事件的顺序优先保障回放完整性。 +- 文件选择、通用文件与 PDF、音频与视频、持久草稿暂存、图片复制、自定义上下文菜单、输出提供方认证和按引用感知的垃圾回收仍是相互独立的设计。 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml index a5e0f31b2d..097c0c9f2d 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md -2026-07-25-session-list-browsing-and-manual-order.md: 323c0db16cc6b9e83155a3fb873ab94e4facd3a2 -2026-07-25-session-list-browsing-and-manual-order.zh.md: 85b2b319f03113480b3953c21940c123ba594ca8 +2026-07-25-session-list-browsing-and-manual-order.md: 3d2125bdf67a70a1a5bca43c5d5acb09fda178b7 +2026-07-25-session-list-browsing-and-manual-order.zh.md: 161ebd2857073d4dd9cfc2883880cd3e2d91c040 diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md index 323c0db16c..3d2125bdf6 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.md @@ -24,7 +24,7 @@ The group-by menu offers two modes, WorkSpace / In one list. WorkSpace mode rend ### workspace.rename -`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with create-by-name, so concurrent explicit naming operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. +`workspace.rename({ workspaceId, title })`: the title is trimmed and must be non-blank; both the same-title no-op and the duplicate check evaluate inside the Host's serialized workspace-operation chain (shared with path adoption and deletion, so concurrent workspace operations cannot interleave a duplicate or an out-of-order fake success), and a conflict returns `workspace-name-conflict`. Path adoption may derive a title already present because canonical path, not title, owns identity ([decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)). Durability goes through `setTitle`'s mutate path, and the `domain/changed` listener broadcasts the `host/workspace-changed` frame automatically. The UI is a standard modal with a client-side duplicate pre-check. ### Manual order: insertSessionBefore replaces activity pinning diff --git a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md index 85b2b319f0..161ebd2857 100644 --- a/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-session-list-browsing-and-manual-order.zh.md @@ -24,7 +24,7 @@ group-by 菜单提供 WorkSpace / In one list 两种模式。WorkSpace 模式按 ### workspace.rename -`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按名称创建共链,并发的显式命名操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 +`workspace.rename({ workspaceId, title })`:title trim 后非空;同名 no-op 与重名查重都在 Host 的 Workspace 操作串行链内求值(与按路径收编和删除共链,并发的 Workspace 操作不能穿插出重名或乱序假成功),冲突返回 `workspace-name-conflict`。按路径收编可以派生出已有 title,因为拥有身份的是 canonical path,而不是 title(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md))。落盘经 `setTitle` 的 mutate 通道,`domain/changed` 监听自动广播 `host/workspace-changed` 帧。UI 为标准 Modal,client 侧另做重名预检。 ### 手动排序:insertSessionBefore 取代活动置顶 diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml index 535d2a86a4..c0813607d3 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md -2026-07-25-workspace-ui-product-flow.md: bfdde9797433eca67e47da73468cba855f2a02b9 -2026-07-25-workspace-ui-product-flow.zh.md: 74a9dd6f70396201a78f3204bfdc7d8b5d4f5d98 +2026-07-25-workspace-ui-product-flow.md: 98e963195126df2ec8291a11b3d9fc7a2baeb0df +2026-07-25-workspace-ui-product-flow.zh.md: 486093be0b8d10c2ae0b8083b305ecad5386351c diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md index bfdde97974..98e9631951 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.md @@ -19,13 +19,12 @@ The Host provides the following GUI wiring on the Workspace entity: | RPC | Behavior | | --- | --- | | `workspace.list` | Returns persistent Workspaces in order and filters out Session ids that fail header validation | -| `workspace.create({ name })` | Creates a directory and Workspace at `workspaceRoot/name`; fails on a display-name conflict | | `workspace.create({ path })` | Adopts an existing directory by canonical path; basename-derived display titles may repeat | | `workspace.delete({ workspaceId })` | Removes the Workspace registration while retaining its directory and session logs; its Sessions become Ungrouped | | `session.create({ workspaceId, sessionId? })` | Resolves cwd from the Workspace, idempotently creates a Session with an optional preallocated id, and attaches it | | `session.create({ cwd })` | Remains available to non-Workspace callers and creates an Ungrouped Session | -`workspaceRoot` is an independent Host setting that falls back to the Host cwd when unset; it is unrelated to `storageRoot`, which stores Workspace domain data. The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). +The Host stream pushes Workspace and Session deltas, including `host/workspace-removed`, and the Client refreshes the `workspace.list` and `session.list` baselines separately after reconnecting. Registration-deletion ownership and safety are defined in the [Workspace registration deletion Agent Note](2026-07-27-workspace-registration-deletion.md). A Workspace's `sessionIds` is an ordered candidate index. A membership projection requires both that an id appear in the index and that the corresponding canonicalized `SessionHeader.cwd` equal the Workspace path; SessionHeader does not gain a `workspaceId`. A Session whose cwd matches but whose id is absent from the index remains Ungrouped, while an indexed id is filtered out if its header is missing, its cwd is invalid, or its cwd does not match. Two Workspace indexes claiming the same Session is corrupt state and fails loudly. @@ -52,7 +51,7 @@ When no Workspace exists, the page creates a frontend Workspace object named `wo Top-level New Session, the plus button on a Workspace row, and the Workspace picker all invoke the same New Session action. An explicit Workspace id becomes the target directly; when none is specified, the action uses the most recent Workspace, or the Workspace Intent if no real Workspace exists. The Workspace picker's one Add workspace action ([one-route Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md); it was a pair of Use-an-existing-folder and create-by-name actions when this was decided) immediately creates a real Workspace when the user confirms a directory, then retargets the frontend Session to it; an explicitly created empty Workspace remains even if the user sends no message. -A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); explicit create-by-name and rename operations retain their duplicate-title checks. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. +A new Workspace takes its display name from the directory it was created in. Distinct canonical paths may share the same basename-derived title ([identity decision](../bug-fix/2026-07-31-same-basename-workspace-adoption.md)); the explicit rename operation retains its duplicate-title check. Moving Sessions across Workspaces, manual adoption from Ungrouped, and separate display-name and directory-name inputs remain outside this flow. ### First send and recovery @@ -108,7 +107,7 @@ The Sidebar and conversation empty hero receive standardized actions through slo - Workspace list performs one reentrant bootstrap using only headers; an initialized empty registry does not initialize again after restart, and membership reads validate both the index and canonical cwd. - The initial default target is determined exactly once after both baselines are ready; Workspace groups are not reordered as a whole by hydration or Session activity, and an active Session moves only itself to the front. - A frontend Session under a real Workspace temporarily counts toward the sidebar total, while a Workspace Intent remains hidden; neither publication nor refresh leaves duplicate rows or counts. -- The UI and Host admit distinct same-basename directories as separate Workspaces, while explicit create-by-name and rename operations reject duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. +- The UI and Host admit distinct same-basename directories as separate Workspaces, while the explicit rename operation rejects duplicate titles; cwd-only Sessions, Sessions with invalid historical cwd values, and unattached Sessions remain Ungrouped. - Confirmed Workspace deletion removes only the registration, retains the current Session, directory, files, and session log, and survives reload; package tests pin unary/frame/baseline races and failure rollback. - Keyless runnable snapshots cover the zero state, explicit creation, and the first send; package-level tests cover bootstrap, membership validation, ordering, idempotency, failure recovery, and arbitrary frame order. diff --git a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md index 74a9dd6f70..486093be0b 100644 --- a/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-workspace-ui-product-flow.zh.md @@ -19,13 +19,12 @@ Host 在 Workspace entity 上提供以下 GUI 接线: | RPC | 行为 | | --- | --- | | `workspace.list` | 返回持久有序的 Workspace,并过滤未通过 header 校验的 Session id | -| `workspace.create({ name })` | 在 `workspaceRoot/name` 创建目录和 Workspace;显示名冲突时失败 | | `workspace.create({ path })` | 按 canonical path 收编已有目录;由 basename 派生的显示名可以重复 | | `workspace.delete({ workspaceId })` | 移除 Workspace 注册记录,同时保留目录和会话日志;相关 Session 进入 Ungrouped | | `session.create({ workspaceId, sessionId? })` | 从 Workspace 解析 cwd,以可选预分配 id 幂等创建 Session 并 attach | | `session.create({ cwd })` | 保留给非 Workspace 调用方,创建 Ungrouped Session | -`workspaceRoot` 是独立 Host 配置,未配置时回退到 Host cwd;它与保存 Workspace domain 数据的 `storageRoot` 无关。Host 流推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 +Host 流推送 Workspace 与 Session 增量,包括 `host/workspace-removed`;Client 重连后分别刷新 `workspace.list` 与 `session.list` 基线。删除注册记录的所有权与安全边界由 [Workspace 注册记录删除 Agent Note](2026-07-27-workspace-registration-deletion.md)定义。 Workspace 的 `sessionIds` 是有序候选索引。成员投影同时要求 id 位于索引且对应 `SessionHeader.cwd` canonical 后等于 Workspace path;SessionHeader 不增加 `workspaceId`。cwd 匹配但未入索引的 Session 保持 Ungrouped,索引命中但 header 缺失、cwd 无效或 cwd 不匹配的 id 被过滤。同一 Session 被两个 Workspace 索引占用属于损坏状态并明确报错。 @@ -52,7 +51,7 @@ Session 自己持有首条输入并驱动一条内部流水线:必要时以预 顶部 New Session、Workspace 行内加号和 Workspace picker 最终都调用同一 New Session 动作:显式 Workspace id 直接成为目标,未指定时使用最近 Workspace,没有真实 Workspace 时使用 Workspace Intent。Workspace picker 的单一 Add workspace 动作(见[单一路径 Note](../simplification/2026-07-31-one-route-to-add-a-workspace.md);本决策做出时是 Use an existing folder 与按名称创建两个动作)会在用户确认目录时立即创建真实 Workspace,再把前端 Session 定位到该 Workspace;即使用户不发送消息,显式创建的空 Workspace 也保留。 -新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的按名称创建和重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 +新建 Workspace 的显示名取自其所在目录。不同 canonical path 可以拥有相同的 basename 派生显示名(见[身份决策](../bug-fix/2026-07-31-same-basename-workspace-adoption.md));显式的重命名操作仍保留显示名重名检查。跨 Workspace 移动 Session、从 Ungrouped 手动收编以及分别输入显示名和目录名仍不在此动线范围内。 ### 首次发送与恢复 @@ -108,7 +107,7 @@ Sidebar 与 conversation empty hero 通过 slot 获得标准化动作:`startSe - Workspace list 只读取 header 完成一次可重入 bootstrap;initialized 的空 registry 重启不重复初始化,成员读取同时校验索引与 canonical cwd。 - 初始默认目标只在两份基线 ready 后确定一次;Workspace 组不因 hydration 或 Session 活跃整体重排,单个活跃 Session 只前移自身。 - 真实 Workspace 下的前端 Session 临时计入 sidebar 数量,Workspace Intent 保持隐藏,发布与刷新都不会留下重复行或重复计数。 -- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的按名称创建和重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 +- UI 与 Host 会将 canonical path 不同但 basename 相同的目录接纳为独立 Workspace,而显式的重命名操作会拒绝重复显示名;cwd-only Session、无效历史 cwd 和未 attach Session 保持 Ungrouped。 - 经确认的 Workspace 删除只移除注册记录,保留当前 Session、目录、文件和会话日志,并在刷新后保持该状态;包级测试固定一元响应/帧/基线竞态和失败回滚行为。 - keyless runnable snapshot 覆盖零态、显式创建和首次发送;包级测试覆盖 bootstrap、成员校验、排序、幂等、失败恢复及任意 frame 顺序。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index cdcbcd8d4e..86b48310e7 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: d74badedaa9623014dbba10ae68ca680daf75de1 -2026-08-04-web-context-source-and-steer-marks.zh.md: b2fd990f6f868fdf422ba97ddd25d19705041e3a +2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 +2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index d74badedaa..01bdca873a 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -37,11 +37,12 @@ The transcript names all three roles a non-prompt message can play — injected ## Testing - `packages/client/runtime` unit coverage pins each source kind, the label fallbacks when a name field is missing, empty, or wrongly typed, the unnamed degradation for a source with no readable kind, and steering reconstruction on reset and live append paths. -- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, the roleless header, and the steering caption on both durable and pending bubbles. -- The keyless assembled-Web goldens carry the named header and the steering caption, so the assembled transcript — not only component tests — proves the marks. +- `packages/client/ui-conversation` jsdom coverage pins the role title, the producer label beside it, the label's survival while expanded, and the roleless header. +- The keyless assembled-Web goldens carry the named header, so the assembled transcript — not only component tests — proves the marks. ## Consequences +- **Superseded in part.** The steering-caption clause of the Decision no longer describes master: the [caption removal](../simplification/2026-08-10-web-remove-steering-interjection-caption.md) deleted the `插话` / `Interjection` caption, leaving a mid-turn steer recognizable only by its position in the flow. The context-source and recall naming in the Decision stays current, and the `SteeringMessageNode` projection is unchanged. - A reader can attribute every non-prompt message in the transcript at a glance, and the header stays honest for logs this client version has never seen a producer for. - Producer names in the UI are package-shaped (`dsh-tool-skill`, `@deepseek-ai/dsh-system-prompt`) wherever the source carries only a plugin id. That is the cost of refusing a client-side name table; a producer that wants a better label must record one in its source fields. - `ContextMessageNode` gains a required field, so every constructed node — including test fixtures — must supply it. diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b2fd990f6f..b6a9cc5692 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -37,11 +37,12 @@ transcript 为非提示消息可能承担的三种角色分别命名:注入上 ## 测试 - `packages/client/runtime` 单元覆盖钉住每个来源分支、名称字段缺失/为空/类型不符时的回退、来源没有可读 kind 时的无名降级,以及 reset 和实时 append 路径上的 steering 重建。 -- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存、无名时的标题形态,以及持久与待处理气泡上的 steering 标注。 -- 无密钥的组装 Web 黄金基线携带带名称的标题栏与 steering 标注,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 +- `packages/client/ui-conversation` 的 jsdom 覆盖钉住角色标题、标题旁的生产者名称、展开后该名称的留存,以及无名时的标题形态。 +- 无密钥的组装 Web 黄金基线携带带名称的标题栏,因此证明这些标识的是组装后的 transcript,而不只是组件测试。 ## 后果 +- **部分被取代。** 决策中的 steering 标注条款已不再描述 master:[标注移除决策](../simplification/2026-08-10-web-remove-steering-interjection-caption.md)删除了 `插话` / `Interjection` 标注,轮次中途的 steer 只能靠它在消息流中的位置辨认。决策中的上下文来源与召回命名仍然有效,`SteeringMessageNode` 投影未变。 - 读者一眼即可归因 transcript 中每一条非提示消息;即便面对本客户端版本从未见过其生产者的日志,标题栏依然如实。 - 只要来源仅携带插件 id,UI 中的生产者名称就呈现为包名形态(`dsh-tool-skill`、`@deepseek-ai/dsh-system-prompt`)。这是拒绝客户端名称表的代价;想要更好标签的生产者必须在来源字段中记录该标签。 - `ContextMessageNode` 增加了一个必填字段,因此每一处构造该节点的代码——包括测试 fixture——都必须提供它。 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml index de5133b527..21b832d99a 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md -2026-08-07-default-model-follows-the-picker.md: 2a3ada55486345c0f58f0767bed5a93ecba04b88 -2026-08-07-default-model-follows-the-picker.zh.md: 08fecc6ec9b177f6424ada3172c67018ac72baea +2026-08-07-default-model-follows-the-picker.md: 10f2d12a017a63d4651add7ce5c4d30aa3c8090c +2026-08-07-default-model-follows-the-picker.zh.md: b232ee22cc6580d85545d26c1a50e27780e00855 diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md index 2a3ada5548..10f2d12a01 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.md @@ -12,7 +12,7 @@ Reasoning effort makes the persistence shape significant: a model selection with ## Decision -`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `workspaceRoot` remains ApiProxy config because it is a Host launcher fact rather than model state. +`AgentDefaultModelService` provides `ctx.agentDefaultModel` and registers `{provider, model, reasoningEffort?}` as the `agent-default-model` Settings section. Its `{provider, model}` composition entry is the base layer and `settings.yaml` supplies the user layer. The service is entry-point-neutral, so direct creation and ApiProxy-backed creation share one default ([headless direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md)). `reasoningEffort` belongs to the Settings section but not to the plugin config. Settings layers merge by field, so a configured effort would survive a user selection that omits it. `saveSelection()` instead writes the complete user section; absence therefore clears a stored effort. A deployment-wide effort default belongs to the adapter profile, which resolves it per model. diff --git a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md index 08fecc6ec9..b232ee22cc 100644 --- a/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md +++ b/.agents/notes/implemented/feature/2026-08-07-default-model-follows-the-picker.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决定 -`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。`workspaceRoot` 仍是 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型状态。 +`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`,并把 `{provider, model, reasoningEffort?}` 注册为 `agent-default-model` Settings 分节。其 `{provider, model}` 组合条目是 base 层,`settings.yaml` 提供用户层。该服务不偏向特定入口,因此直接创建与 ApiProxy 支撑的创建共享同一个默认值([headless 直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md))。 `reasoningEffort` 属于 Settings 分节,但不属于插件配置。Settings 层按字段合并,因此已配置的强度会在用户选择省略它时继续存在。`saveSelection()` 写入完整的用户分节;缺席值由此清除已存强度。部署级强度默认值属于适配器 profile,并由它按模型解析。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.i18n.yaml new file mode 100644 index 0000000000..8b782a8225 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.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/simplification/2026-07-29-simplify-web-image-input-v1.md +2026-07-29-simplify-web-image-input-v1.md: e7847dc2ae18f48146cb2b686dcda64beebf2350 +2026-07-29-simplify-web-image-input-v1.zh.md: c9e0a3cc5dc07b70be4b0c1244921ccac567d9af diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md new file mode 100644 index 0000000000..e7847dc2ae --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.md @@ -0,0 +1,37 @@ +# Agent Note: Simplify Web image input version one + +Status: implemented + +English | [中文](2026-07-29-simplify-web-image-input-v1.zh.md) + +## Problem + +The first durable Web image-input slice introduced required ordered multi-image intake alongside speculative surfaces for arbitrary CLI provider mounting, output-modality discovery, alternative text, provider-neutral visual token pricing, and browser lifecycle APIs with no cross-package consumer. Keeping the speculative surfaces would turn unchosen future behavior into public contracts and make the initial capability harder to review and maintain. + +## Decision + +Version one accepts ordered image batches bounded by configurable per-message count and aggregate-byte limits plus per-image byte and pixel limits. The browser rejects unsupported declared formats before preview allocation, while the host authoritatively decodes the complete batch, checks current deployment bounds, validates every image without storage writes, and only then saves every image while preserving submitted order in the resulting durable blocks. Request buffering derives directly from the attachment service's aggregate image-byte limit. This preserves validation atomicity without adding a batch transaction, rollback protocol, or policy snapshot in `host.describe`. + +Provider/model selection remains configuration and profile state. The boot composition registers the shipped DeepSeek, OpenAI, and Anthropic routes; the CLI does not add image-specific selection flags, inspect the yml provider roster, or dynamically mount an adapter. + +Exact-model metadata carries only the input modalities that current admission decisions consume. `ImageBlock` carries the durable attachment reference; its optional display name supplies accessible UI text, so the core block has no separate alternative-text field. Provider-neutral token estimation does not apply one provider's visual pricing formula to other routes. + +The attachment seam exposes its limits plus storage-free `validateImage`, `saveImage`, and `readImage`. The host depends on that seam rather than implementation re-exports. Browser draft and historical-image operations remain concrete conversation-plugin internals; the public `IConversation` face contains only the input registry and the scoped send, cancel, and history verbs used across package boundaries. + +## Alternatives considered + +**Accept only one image.** Comparing or combining several images is a current product requirement. Count and aggregate-byte bounds keep that path finite without reducing it to a single image. + +**Add a storage transaction or rollback protocol.** Storage-free validation prevents malformed later members from leaving earlier valid members unreferenced. Stronger all-or-nothing storage across independent content-addressed objects would require ownership or reclamation semantics that the current product path does not need. + +**Keep future-facing fields and methods as placeholders.** Output modalities, block alternative text, and active-model handshake data had no current decision consumer. Adding them later with their first consumer preserves freedom to choose the correct contract. + +**Estimate every image with one tile formula.** Visual pricing varies by provider, model, detail mode, and preprocessing. A hard-coded provider-neutral estimate would look authoritative while being wrong; provider usage is the authoritative accounting source. + +**Add CLI provider/model selection or dynamic mounting.** Configuration already owns route selection, plugin composition, and credentials. Duplicating those choices in image-input flags would require parsing or mutating the config tree outside the loader. + +## Consequences + +The feature retains the two batch limits and one storage-free validation method required by multi-image prompts, while removing unrelated public fields, lifecycle operations, policy snapshots, and route-assembly branches. Provider/model selection remains composition or profile configuration. Pre-request token pressure may undercount visual input until a provider-aware estimator is designed, while reported usage remains exact. + +Reintroducing any removed surface requires a concrete consumer and its failure, lifecycle, replay, and testing contract rather than compatibility with this pre-release shape. diff --git a/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md new file mode 100644 index 0000000000..c9e0a3cc5d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-simplify-web-image-input-v1.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 简化 Web 图片输入第一版 + +Status: implemented + +[English](2026-07-29-simplify-web-image-input-v1.md) | 中文 + +## 问题 + +首个持久化 Web 图片输入切片在引入按顺序接收多张图片的必需能力时,也引入了由 CLI(命令行界面)挂载任意提供方、输出模态发现、替代文本、提供方无关的视觉 token 定价,以及没有跨包(package)消费方的浏览器生命周期 API 等推测性表面。保留这些推测性表面会把尚未选择的未来行为变成公共契约,并使初始能力更难评审和维护。 + +## 决策 + +第一版接受有序图片批次,并以可配置的每条消息图片数量与图片总字节数上限,以及单张图片字节数与像素上限约束批次。浏览器会在分配预览前拒绝声明不支持的格式,而宿主会以权威方式解码完整批次、检查当前部署上限、在不写入存储的情况下校验每张图片,之后才保存每张图片,并在生成的持久图片块中保留提交顺序。请求缓冲上限直接由附件服务的图片总字节数上限派生。这样无需添加批次事务、回滚协议或 `host.describe` 中的策略快照,就能保持校验的原子性。 + +提供方/模型选择仍属于配置和 profile 状态。启动组合会注册交付的 DeepSeek、OpenAI 和 Anthropic 路由;CLI 不会添加图片专用选择标志、检查 yml 提供方清单或动态挂载适配器。 + +确切模型元数据只携带当前准入决策会消费的输入模态。`ImageBlock` 携带持久附件引用;其可选显示名称提供无障碍 UI 文本,因此核心块没有单独的替代文本字段。与提供方无关的 token 估算不会把某一提供方的视觉定价公式应用于其他路由。 + +附件服务边界公开其限制、不触碰存储的 `validateImage`,以及 `saveImage` 和 `readImage`。宿主依赖该服务边界,而不是实现层重新导出的内容。浏览器草稿和历史图片操作仍是具体会话插件的内部实现;公开的 `IConversation` 表面只包含输入注册表,以及跨包边界使用的按作用域发送、取消和历史记录操作。 + +## 曾考虑的替代方案 + +**只接受一张图片。** 比较或组合多张图片是当前产品要求。图片数量和总字节数上限使这条路径保持有界,而无需将其缩减为单张图片。 + +**添加存储事务或回滚协议。** 不触碰存储的校验能防止后面的畸形成员使前面有效的成员成为无引用对象。若要在独立的内容寻址对象之间实现更强的全有或全无存储保证,就需要当前产品路径并不需要的所有权或回收语义。 + +**将面向未来的字段和方法保留为占位符。** 输出模态、块替代文本和活跃模型握手数据目前都没有决策消费方。等到第一个消费方出现时再加入这些内容,可以保留选择正确契约的自由。 + +**使用一种图块公式估算每张图片。** 视觉定价因提供方、模型、细节模式和预处理而异。一项硬编码且与提供方无关的估算会看似权威,实际却是错误的;提供方用量才是权威核算来源。 + +**添加 CLI 提供方/模型选择或动态挂载。** 配置已经负责路由选择、插件组合和凭据。在图片输入标志中重复这些选择,会要求在 loader 之外解析或修改配置树。 + +## 后果 + +该功能保留了多图片提示词所需的两个批次上限和一个不触碰存储的校验方法,同时移除了无关的公开字段、生命周期操作、策略快照和路由组装分支。提供方/模型选择仍属于组合或 profile 配置。在设计出提供方感知型估算器之前,请求前的 token 压力计算可能少计视觉输入,而上报的用量仍保持精确。 + +重新引入任何已移除表面时,都必须有具体消费方,并为其定义失败、生命周期、回放和测试契约,而不是为了兼容这一预发布形态。 diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml index 625bb457ea..05f3050df7 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.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/simplification/2026-07-31-one-route-to-add-a-workspace.md -2026-07-31-one-route-to-add-a-workspace.md: 46bd5d5ca5ecfbc340fcb46b6da5e9f237764ab1 -2026-07-31-one-route-to-add-a-workspace.zh.md: 6a6e8b3fc9f64a62d3264f4c7779d11c8515e447 +2026-07-31-one-route-to-add-a-workspace.md: 4de79b7db30853a13319aa016d4eb5fb5d9ec1f1 +2026-07-31-one-route-to-add-a-workspace.zh.md: 2b305494c5d61db11f9ecf247c323040e6a549bf diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md index 46bd5d5ca5..4de79b7db3 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md @@ -25,9 +25,9 @@ The direct-open path carries the busy rule the menu entry states: while a pick i `WorkspaceCreateFlow` is now `WorkspacePickFlow` and its `createOnly` prop is `addOnly`; the injected `createWorkspace` narrows from `{ name } | { path }` to `{ path }`. -## Wire and CLI residue +## Wire and CLI surface -The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace-root` still feeds its target directory, but no product surface reaches either any more. The same is true of the client contract that carried the name to the wire: `WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, `intentName`'s name branch, and the manager's "name under workspaceRoot" contract. `apps/cli/README.md` and its Chinese counterpart still document `--workspace-root` as creating named Workspaces. The whole set is marked for deletion at the call site in `packages/host/apiproxy/src/api-proxy.ts` and left to a follow-up change: it is backend, client-contract, and CLI surface with its own test fallout (the api-proxy workspace suite, the runtime workspace suite, the config catalog), and the release-blocking part of this decision is the UI. +`workspace.create` accepts only `{ path }`; the wire schema and `WorkspaceApi` have no `name` member. The gateway has no `workspaceRoot` config, the client contract exposes only path adoption through `WorkspaceCreateInput`, `WorkspacesService.create`, and `intentName`, and `dsh web` has no `--workspace-root` flag. `workspace-name-conflict` remains on the wire as `workspace.rename`'s duplicate-title error. ## Testing @@ -45,13 +45,13 @@ The host's `workspace.create` still accepts `{ name }`, and `dsh web --workspace **Keep the menu shell for entries we might add later (clone a repo, remote directory).** Rejected under "require a current owner and need": no such entry exists, and restoring a menu when one arrives is a smaller change than shipping an empty frame now. -**Delete the wire's create-by-name branch in the same change.** Rejected here: it is backend/CLI surface with a wider test fallout, and the urgent decision is the UI. See the residue section — it is marked, not forgotten. +**Delete the wire's create-by-name branch in the same change.** Rejected because the UI decision did not depend on the backend and CLI deletion, whose separate contracts and tests formed an independently reviewable change. **Register the workspace through the host in the e2e scaffold instead of driving the dialog.** Rejected: it would have decoupled all 15 scenarios from the picker, so nothing in the lane would prove the surviving route reaches a live composer. Every scenario now walks the real dialog to adopt its directory; only the create-a-folder half is concentrated in one scenario, because repeating it everywhere makes the shared helper non-idempotent for no extra signal. ## Consequences -- Creating a Workspace outside the operator's chosen directory is no longer possible from the UI; the server-controlled `--workspace-root` target was the one way to constrain where new workspace folders land, and nothing replaces it. A deployment that needs that constraint has to re-introduce it deliberately. -- The one remaining route browses the host filesystem, so the picker's reach is now the whole host rather than one configured parent. That is already the browse occupant's contract; this change makes it the only contract. -- A composition that mounts `ui-workspace` without any directory-picker package can no longer add a Workspace at all, and now says so by omitting the button instead of offering a create-by-name fallback. +- The UI creates Workspace folders only under a directory the operator chooses. No server-controlled configuration constrains that location; a deployment that needs such a constraint must add it deliberately. +- The picker's configured reach defines the host filesystem available to the remaining route; there is no separate configured parent. +- A composition that mounts `ui-workspace` without a directory-picker package cannot add a Workspace and omits the button. - The hero chip still announces `aria-haspopup="menu"` while the direct-open path raises a dialog instead. Making that truthful means routing the flow's presentation choice up through the `conversation.hero.workspace` owner contract — the flow owns the decision, the chip owns the announcement, and they sit in different packages — so it is a named follow-up rather than a silent inconsistency. The sidebar button this change added makes no popup claim at all. diff --git a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md index 6a6e8b3fc9..2b305494c5 100644 --- a/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.zh.md @@ -25,9 +25,9 @@ Status: implemented `WorkspaceCreateFlow` 现更名为 `WorkspacePickFlow`,其 `createOnly` prop 更名为 `addOnly`;注入的 `createWorkspace` 从 `{ name } | { path }` 收窄为 `{ path }`。 -## Wire and CLI residue +## Wire 与 CLI 表层 -Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` 也仍在为它提供目标目录,但已没有任何产品表层会走到它们。把名称送到 wire 的客户端约定同样如此:`WorkspaceCreateInput`、`WorkspacesService.create` 的 `{ name }` 分支、`intentName` 的名称分支,以及 manager 中"workspaceRoot 下的 name"这一约定。`apps/cli/README.md` 及其中文对照本也仍把 `--workspace-root` 记为"创建具名 Workspace"。这一整套都在 `packages/host/apiproxy/src/api-proxy.ts` 的调用点标记为待删除,并留给后续改动:它横跨 backend、客户端约定与 CLI 面,有各自的测试波及面(api-proxy workspace 套件、runtime workspace 套件、配置目录),而本决定中阻塞发布的部分是 UI。 +`workspace.create` 只接受 `{ path }`;wire schema 与 `WorkspaceApi` 没有 `name` 成员。网关没有 `workspaceRoot` 配置;客户端约定只通过 `WorkspaceCreateInput`、`WorkspacesService.create` 与 `intentName` 提供按路径接纳,`dsh web` 没有 `--workspace-root` flag。`workspace-name-conflict` 仍保留在 wire 上,作为 `workspace.rename` 的标题重名错误。 ## Testing @@ -45,13 +45,13 @@ Host 侧的 `workspace.create` 仍接受 `{ name }`,`dsh web --workspace-root` **为将来可能新增的入口(克隆仓库、远程目录)保留菜单壳。** 否决,依据"require a current owner and need":这样的入口目前并不存在,而等它到来时再恢复菜单,比现在就发一个空壳的改动更小。 -**在同一改动中删除 wire 的按名称创建分支。** 在此否决:那是 backend/CLI 面,测试波及面更广,而紧急的决定是 UI。见 residue 一节——它是被标记了,不是被遗忘了。 +**在同一改动中删除 wire 的按名称创建分支。** 否决,因为 UI 决定不依赖后端与 CLI 删除,后两者各自的约定和测试构成一项可独立评审的改动。 **在 e2e scaffold 中经 host 注册 workspace,而不驱动对话框。** 否决:那会让全部 15 个场景与选择器解耦,整条 lane 将无法证明幸存的这条路径能走到可用的 composer。现在每个场景都会走真实对话框来接纳自己的目录;只有"新建文件夹"那一半集中在一个场景里,因为处处重复只会让共享辅助函数失去幂等性,却换不来额外信号。 ## Consequences -- 从 UI 已无法在操作者选定目录之外创建 Workspace;服务端控制的 `--workspace-root` 目标曾是约束新 workspace 文件夹落点的唯一手段,现在没有替代品。需要该约束的部署必须有意识地重新引入它。 -- 仅存的这条路径会浏览宿主机文件系统,因此选择器的可达范围现在是整台宿主机,而非一个配置好的父目录。这本就是浏览占用者的约定,本改动使它成为唯一的约定。 -- 挂载了 `ui-workspace` 但未挂任何 directory-picker 包的组合,已完全无法添加 Workspace;现在它通过不渲染按钮来说明这一点,而不是提供一个按名称创建的兜底。 +- UI 只在操作者选择的目录下创建 Workspace 文件夹。没有服务端控制的配置约束其位置;需要该约束的部署必须明确加入该约束。 +- picker 配置的可达范围决定剩余路径可用的 Host 文件系统;不存在另一个配置好的父目录。 +- 挂载 `ui-workspace` 但没有 directory-picker 包的组合无法添加 Workspace,也不渲染按钮。 - 主视觉区的 chip 仍声明 `aria-haspopup="menu"`,而直接拉起的路径实际弹出的是对话框。要让它如实,需要把流程的展示方式经 `conversation.hero.workspace` 的 owner 约定上报——决定权在流程,播报权在 chip,两者分属不同的包——因此这被列为一项具名的后续工作,而不是一处无声的不一致。本次新增的侧边栏按钮完全不作任何 popup 声明。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.i18n.yaml new file mode 100644 index 0000000000..2f63bbcfd7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.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/simplification/2026-08-10-web-remove-steering-interjection-caption.md +2026-08-10-web-remove-steering-interjection-caption.md: 2c396f54945fb1c3626f2e5fcc849e81893c23f0 +2026-08-10-web-remove-steering-interjection-caption.zh.md: 088b36449130f9e8f1be5bec7a3ee4a812b4b6f1 diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md new file mode 100644 index 0000000000..2c396f5494 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.md @@ -0,0 +1,36 @@ +# Agent Note: Remove the steering interjection caption + +Status: implemented + +English | [中文](2026-08-10-web-remove-steering-interjection-caption.zh.md) + +## Problem + +The [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) captioned every durable and pending steering bubble with `插话` / `Interjection` so the transcript could say which right-aligned bubble interrupted a running turn. The caption repeats what the flow already shows: a steering bubble sits mid-turn, between the assistant content it interrupted, while a turn-opening prompt sits at a turn boundary. A permanent line of tertiary text above every steer bubble buys no reading a position-aware reader does not already have, and it is the only chrome any user-style bubble carries, so it also breaks the otherwise uniform right-aligned rhythm. + +## Decision + +Steering renders exactly as a user bubble. `UserStyleBubble` has no steering flag, the `message.steering` locale key and the `.steeringMark` style are deleted, and `PendingSteeringBubble` and `UserMessageNodeView` pass only content and actions. A mid-turn steer is recognizable by its position inside the running turn's flow, and by nothing else. + +The runtime distinction is untouched. `SteeringMessageNode` projection from durable `agent/inbox/spliced` history, the `data-pending-steering` attribute, and the pending-to-durable hand-off all remain: the pending lifecycle needs the node identity regardless of presentation, and tests still locate pending bubbles through the attribute. + +This partially supersedes the steering clause of the [context-source and steer marks decision](../feature/2026-08-04-web-context-source-and-steer-marks.md); its context-source and recall naming stays current. The caption has flipped before: the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md) removed it while the composer could not steer, and the 2026-08-04 decision reintroduced it after the composer gained a Steer gesture. This removal does not revisit the gesture — steering entry, the Queue dock's steer-send action, and the pending lifecycle keep their owners — it judges only that the transcript need not name the result. + +## Alternatives considered + +**Keep the caption.** It is the status quo and cheap to keep, but it decorates every steer bubble forever to encode a fact the bubble's position already states. Chrome that carries no information a reader lacks is removed, not maintained. + +**Remove the `SteeringMessageNode` distinction too.** The node kind is derived from durable inbox history and drives the pending-to-durable hand-off; it is a replay fact, not presentation. Folding it into `UserMessageNode` would change projection behavior for no UI gain. + +**Distinguish steering with quieter chrome (tint, indent, hover-only label).** Any replacement re-raises the same question with a weaker vocabulary. The distinction the transcript needs is positional and already visible; adding subtler decoration keeps the cost and loses the one virtue the text caption had, being explicit. + +## Testing + +- `packages/client/ui-conversation` jsdom coverage pins the plain bubble: the pending hand-off test locates pending bubbles by `data-pending-steering` and asserts the single-bubble hand-off without any caption, and the MessageItem steering arm asserts copy-without-branch on an uncaptioned bubble. +- The keyless assembled-Web goldens (`steering/mid-steer`, `steering/settled`, `plan-review/approved`) replay the unchanged session fixtures with no caption text. + +## Consequences + +- A replayed transcript no longer names steering: a reader infers a mid-turn interjection from its position inside the turn. That inference is weaker than an explicit label for a reader skimming turn boundaries; the decision accepts this. +- A pending steer bubble is visually identical to an ordinary sent bubble until admission; only its missing clock time differs. +- Reintroducing steering chrome of any form requires a new product decision superseding this note. diff --git a/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md new file mode 100644 index 0000000000..088b364491 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-web-remove-steering-interjection-caption.zh.md @@ -0,0 +1,36 @@ +# Agent Note: 移除 steering 插话标注 + +Status: implemented + +[English](2026-08-10-web-remove-steering-interjection-caption.md) | 中文 + +## 问题 + +[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)给每个持久与待处理的 steering 气泡加上了 `插话` / `Interjection` 标注,让 transcript 能说明哪条右对齐气泡打断了正在运行的轮次。这个标注重复了消息流已经呈现的事实:steering 气泡位于轮次中途、夹在被它打断的助手内容之间,而开轮提示位于轮次边界。在每个 steer 气泡上方常驻一行三级文字,并没有让一个能看到位置的读者多读出任何信息,而且它是所有用户样式气泡中唯一带装饰的,还破坏了原本统一的右对齐节奏。 + +## 决策 + +steering 完全按用户气泡渲染。`UserStyleBubble` 不再有 steering 标志,`message.steering` locale 键与 `.steeringMark` 样式已删除,`PendingSteeringBubble` 与 `UserMessageNodeView` 只传内容与操作。轮次中途的 steer 只能靠它在运行轮次消息流中的位置辨认,除此之外没有任何标识。 + +运行时的区分保持不变。从持久 `agent/inbox/spliced` 历史投影 `SteeringMessageNode`、`data-pending-steering` 属性、待处理到持久的交接全部保留:待处理生命周期无论呈现如何都需要节点身份,测试也仍通过该属性定位待处理气泡。 + +本决策部分取代[上下文来源与 steer 标识决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)中的 steering 条款;其上下文来源与召回命名仍然有效。这个标注此前已经翻转过一次:[已归档的取消 steer 装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)在 composer 无法 steer 时移除了它,2026-08-04 的决策在 composer 获得 Steer 手势后把它加了回来。本次移除不重议手势本身——steering 入口、Queue dock 的插话发送操作、待处理生命周期各归其主——只判定 transcript 不需要为其结果命名。 + +## 考虑过的替代方案 + +**保留标注。** 它是现状,维持成本低,但它永久装饰每个 steer 气泡,只为编码气泡位置已经陈述的事实。不承载读者缺少的信息的装饰应当删除,而不是维护。 + +**连 `SteeringMessageNode` 区分一起删。** 节点类型派生自持久 inbox 历史,驱动待处理到持久的交接;它是回放事实,不是呈现。把它并入 `UserMessageNode` 会改变投影行为,却没有任何 UI 收益。 + +**换更安静的装饰(底色、缩进、悬停标签)。** 任何替代装饰都会用更弱的表达重新提出同一个问题。transcript 需要的区分是位置性的、已经可见的;换成更含蓄的装饰保留了成本,却丢掉了文字标注唯一的优点,就是明确。 + +## 测试 + +- `packages/client/ui-conversation` 的 jsdom 覆盖固定了纯气泡行为:待处理交接测试通过 `data-pending-steering` 定位待处理气泡,在没有任何标注的前提下断言单气泡交接;MessageItem 的 steering 分支在无标注气泡上断言可复制且无分支操作。 +- 无密钥的组装 Web goldens(`steering/mid-steer`、`steering/settled`、`plan-review/approved`)用未变的会话 fixture 回放,不含标注文字。 + +## 后果 + +- 回放的 transcript 不再为 steering 命名:读者靠消息在轮次中的位置推断这是一次中途插话。对快速扫读轮次边界的读者,这个推断弱于显式标签;本决策接受这一代价。 +- 待处理的 steer 气泡在被准入前与普通已发送气泡在视觉上完全一致,仅缺少时钟时间。 +- 重新引入任何形式的 steering 装饰都需要一个取代本 note 的新产品决策。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 55c753b45d..fec5de6128 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -83,6 +83,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`pnpm`](https://github.com/pnpm/pnpm) | MIT | | [`react`](https://github.com/facebook/react) | MIT | | [`react-dom`](https://github.com/facebook/react) | MIT | +| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | | [`shiki`](https://github.com/shikijs/shiki) | MIT | | [`supports-color`](https://github.com/chalk/supports-color) | MIT | | [`tsx`](https://github.com/privatenumber/tsx) | MIT | diff --git a/apps/cli/composition.md b/apps/cli/composition.md index cfcd903e32..aef0dc6a7b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -46,6 +46,8 @@ flowchart LR cfg --> plugin_dsh_base_llm_pi_ai plugin_dsh_base_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_dsh_base_session_persistence_jsonl + plugin_dsh_base_attachment_local["attachment-local
@deepseek-ai/dsh-attachment-local"] + cfg --> plugin_dsh_base_attachment_local plugin_dsh_base_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] cfg --> plugin_dsh_base_session_query_sqlite plugin_dsh_base_session_projection["session-projection
@deepseek-ai/dsh-session-projection"] @@ -183,6 +185,7 @@ flowchart LR | `credentials` | `@deepseek-ai/dsh-credentials-local` | | `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `attachment-local` | `@deepseek-ai/dsh-attachment-local` | | `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | | `session-projection` | `@deepseek-ai/dsh-session-projection` | | `telemetry-otel` | `@deepseek-ai/dsh-session-telemetry-otel` | diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 0a03868e2b..cb7b0a1ebf 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/reference/README.md -README.md: bb3e1d77f00e03f6a21f4da3c04994f208e84293 -README.zh.md: 3802f940ef6663ec215cf09293a9edda5ea4c512 +README.md: 40807da0b1a88708e2e5e9efa3569bd0d6f6a020 +README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index bb3e1d77f0..40807da0b1 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -43,7 +43,7 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, `--workspace-root`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 3802f940ef..b9c48c16dd 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -43,7 +43,7 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port`、`--workspace-root` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index d96b7a503f..e2e8a89980 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -48,7 +48,6 @@ interface WebInvocation { host?: string port?: number dev: boolean - workspaceRoot?: string /** Extra authorities for the /api browser-trust fence. */ trustedHosts?: string[] } @@ -70,7 +69,6 @@ interface WebOptions { host?: string port?: string dev?: boolean - workspaceRoot?: string trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean @@ -176,7 +174,6 @@ Examples: .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') @@ -196,8 +193,8 @@ Examples: // dropping them would print a tree that differs from the same // invocation's boot. if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.workspaceRoot !== undefined || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--workspace-root/--trusted-host)') + || options.trustedHost !== undefined) { + program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') } resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } return @@ -211,7 +208,6 @@ Examples: ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, - ...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot }, ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, } }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index bdf301e2ae..e438c7da7c 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--workspace-root/ - * --trusted-host`), each flag becoming a patch over the composed profile + * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag + * becoming a patch over the composed profile * tree. All web runtime glue (dist serving, prompt section, URL line) lives * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives * flag patches and the LAN-trust snapshot. @@ -59,7 +59,6 @@ export interface WebFlags { host?: string port?: number dev: boolean - workspaceRoot?: string trustedHosts?: string[] } @@ -83,7 +82,6 @@ function deriveWebFlagPatches( } if (flags.host !== undefined) put('webserver', 'host', flags.host) if (flags.port !== undefined) put('webserver', 'port', flags.port) - if (flags.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', flags.workspaceRoot) const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) if (trustedHosts.length > 0) { @@ -122,8 +120,8 @@ export function webSurfaceContextEnabled(rows: ProfileRows): boolean { } /** - * Serve the browser UI from the web profile. Host/port/workspace-root flags - * are passed through only when given (absent, the composed profile values + * Serve the browser UI from the web profile. Host/port flags are passed + * through only when given (absent, the composed profile values * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on * every boot. The URL line is printed by the web-app bundle's runtime row * after Loader settlement. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index b8e1910a00..603d8e2dd5 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -33,8 +33,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) - expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w', patches: [] }) + expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) + .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) }) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index 53b0a406ce..0b82d6d808 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -44,9 +44,15 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { return page.getByRole('dialog', { name: '设置' }) } - /** Tokenize the lane-owned preset root the way the scaffold tokenizes cwd. */ + /** Tokenize the lane-owned preset root after general aria normalization. */ function withPresetRoot(snapshot: string): string { - return snapshot.split(userRoot).join('{{presetRoot}}') + const rootSuffix = `/${userRoot.split('/').pop()!}` + return snapshot.split('\n').map((line) => { + const rootStart = line.indexOf(rootSuffix) + if (rootStart === -1) return line + const pathStart = line.lastIndexOf(' ', rootStart) + 1 + return `${line.slice(0, pathStart)}{{presetRoot}}${line.slice(rootStart + rootSuffix.length)}` + }).join('\n') } beforeAll(async () => { diff --git a/apps/web/tests/image-display.snapshot.ts b/apps/web/tests/image-display.snapshot.ts new file mode 100644 index 0000000000..6546a79f1b --- /dev/null +++ b/apps/web/tests/image-display.snapshot.ts @@ -0,0 +1,138 @@ +// @vitest-environment jsdom +// Multimodal image surfaces over the BUILT client graph (the code-mode-fixture +// idiom: real bundles via AppWebEntry, keyless FixtureApiClient transport). +// Opens the fixture history session whose turn 72 carries an image in BOTH a +// user message and an assistant message, and pins the product surfaces: the +// history ImageGallery loading real fixture bytes through the authorized +// sessions.attachment route, the double-click ImageLightbox, and the composer +// intake chain (paste → ordered thumbnail rail → image-only send enablement → remove). +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open the fixture history session (the alpha log carrying the turn-72 image pair) and wait for its gallery. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) + const group = (await within(tree).findAllByText('fixture')) + .map(el => el.closest('[role="treeitem"]')) + .find(el => el?.getAttribute('aria-expanded') !== null) + if (group === null || group === undefined) throw new Error('fixture Workspace group missing') + if (group.getAttribute('aria-expanded') === 'false') { + fireEvent.click(within(group).getByText('fixture')) + await waitFor(() => { + expect(group.getAttribute('aria-expanded')).toBe('true') + }) + } + const session = await within(tree).findByText('Fixture 历史会话') + fireEvent.click(session) + await waitFor(() => { + expect(document.querySelectorAll('[data-align] img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) +} + +it('renders the history image pair through the authorized attachment route and opens the lightbox', async () => { + localStorage.setItem('dsh.locale', 'zh') + mountAssembledApp() + await openFixtureSession() + + // Both the user-side (align=end) and assistant-side (align=start) galleries + // load real fixture bytes over sessions.attachment. jsdom provides + // createObjectURL, so this environment MUST take the object-URL path — a + // data: src here would mean the fallback ran where it should not. + await waitFor(() => { + if (document.querySelector('[data-align="end"] img') === null + || document.querySelector('[data-align="start"] img') === null) { + throw new Error('history image galleries missing') + } + }, { timeout: 10_000 }) + const galleryShape = (align: string) => [...document.querySelectorAll(`[data-align="${align}"] img`)] + .map(img => ({ alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0] })) + expect({ user: galleryShape('end'), assistant: galleryShape('start') }).toMatchInlineSnapshot(` + { + "assistant": [ + { + "alt": "fixture-image.png", + "scheme": "blob", + }, + ], + "user": [ + { + "alt": "fixture-image.png", + "scheme": "blob", + }, + ], + } + `) + const userImage = document.querySelector('[data-align="end"] img')! + + // Double-click opens the original-size lightbox; Escape/close dismisses it. + const frame = userImage.closest('button') + if (frame === null) throw new Error('image frame button missing') + fireEvent.doubleClick(frame) + const lightbox = await screen.findByRole('dialog') + expect(within(lightbox).getByRole('img').getAttribute('src')?.split(':')[0]).toBe('blob') + fireEvent.click(within(lightbox).getByRole('button', { name: /关闭/ })) + await waitFor(() => { + expect(screen.queryByRole('dialog')).toBeNull() + }) +}) + +it('accepts pasted images into the composer rail in order and removes them', async () => { + localStorage.setItem('dsh.locale', 'zh') + mountAssembledApp() + + const tree = await screen.findByRole('tree', { name: '会话' }, { timeout: 10_000 }) + const start = tree.querySelector('button[aria-label="在“fixture”中新建会话"]') + if (start === null) throw new Error('fixture Workspace new-session action missing') + fireEvent.click(start) + + // Image-only send arming is pinned at package level (input-bar.spec.tsx); + // this assembled lane pins the intake chain over the built graph. + const textarea = await screen.findByPlaceholderText('描述你想要构建的内容', {}, { timeout: 10_000 }) + const image = new File([new Uint8Array([137, 80, 78, 71])], 'pasted.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => image }], + getData: () => '', + }, + }) + + // The rail is an accessible group holding the draft thumbnail (queried via + // DOM: jsdom's a11y-visibility computation hides the composer subtree). + const rail = await waitFor(() => { + const el = document.querySelector('[role="group"][aria-label="待发送图片"]') + if (el === null) throw new Error('attachment rail missing') + return el + }, { timeout: 5_000 }) + expect([...rail.querySelectorAll('img')].map(img => ({ + alt: img.getAttribute('alt'), scheme: img.getAttribute('src')?.split(':')[0], + }))).toMatchInlineSnapshot(` + [ + { + "alt": "pasted.png", + "scheme": "blob", + }, + ] + `) + + const second = new File([new Uint8Array([137, 80, 78, 71])], 'second.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [{ kind: 'file', type: 'image/png', getAsFile: () => second }], + getData: () => '', + }, + }) + await waitFor(() => { + expect([...rail.querySelectorAll('img')].map(img => img.getAttribute('alt'))) + .toEqual(['pasted.png', 'second.png']) + }) + + const remove = [...rail.querySelectorAll('button[aria-label^="移除图片"]')] + if (remove.length !== 2) throw new Error('remove buttons missing') + for (const button of remove) fireEvent.click(button) + await waitFor(() => { + expect(document.querySelector('[role="group"][aria-label="待发送图片"]')).toBeNull() + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index cc6d007d82..9abc8b4b6d 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -78,8 +78,8 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) // The workspace-aware flow runs sessions in /workspace; - // the read targets must live in that session cwd (pre-creation is safe: - // create-by-name adopts an existing directory). + // the read targets must live in that session cwd (pre-creation is safe + // because the picker adopts an existing directory by path). const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'nav-a.md'), '# alpha nav\n') diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index d7080a5ed7..80ef25ba39 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -187,8 +187,8 @@ describe('web e2e: seeded history renders through cold resume', () => { scaffold = await launchWebScaffold({}) // The workspace-aware flow runs sessions in /workspace // (the composer's default draft name); the read-tool targets must live in - // that session cwd. Pre-creating the directory is safe: create-by-name - // adopts an existing directory. + // that session cwd. Pre-creating the directory is safe because the picker + // adopts an existing directory by path. const sessionCwd = join(scaffold.workspaceCwd, 'workspace') await mkdir(sessionCwd, { recursive: true }) await writeFile(join(sessionCwd, 'a.txt'), 'alpha\n') diff --git a/apps/web/tests/snapshots/plan-review/approved.expected.md b/apps/web/tests/snapshots/plan-review/approved.expected.md index 0ba0ebe1da..cdd844062a 100644 --- a/apps/web/tests/snapshots/plan-review/approved.expected.md +++ b/apps/web/tests/snapshots/plan-review/approved.expected.md @@ -7,7 +7,7 @@ - tab "Chat" [selected] - tab "Trajectory" - img -- text: "plan Plan mode on. Use /plan off to leave. Interjection Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" +- text: "plan Plan mode on. Use /plan off to leave. Plan a small change: add a --greeting flag to a CLI. Do not read or write any files. Call exit_plan_mode with a short plan of at most five bullet points. Once the plan is approved, reply with the single word DONE and stop. {{clock}}" - button "Copy": - img - button "Context injection @deepseek-ai/dsh-system-prompt": diff --git a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md index 222097e0a3..8b77a77a0c 100644 --- a/apps/web/tests/snapshots/steer-all/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steer-all/mid-steer.expected.md @@ -19,10 +19,10 @@ - img - text: Think The user wants me to ask them a checkpoint question first, then continue with whatever they interject. Let me do exactly that. - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply." +- text: "Interjection: include the word ORANGE in your final reply." - button "Copy": - img - textbox "Message the agent" diff --git a/apps/web/tests/snapshots/steer-all/settled.expected.md b/apps/web/tests/snapshots/steer-all/settled.expected.md index d9f1893344..0899529a09 100644 --- a/apps/web/tests/snapshots/steer-all/settled.expected.md +++ b/apps/web/tests/snapshots/steer-all/settled.expected.md @@ -21,10 +21,10 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img -- text: "Interjection Interjection: include the word ORANGE in your final reply. {{clock}}" +- text: "Interjection: include the word ORANGE in your final reply. {{clock}}" - button "Copy": - img - paragraph: "Got it: BANANA and ORANGE." diff --git a/apps/web/tests/snapshots/steering/mid-steer.expected.md b/apps/web/tests/snapshots/steering/mid-steer.expected.md index 0100e28b9a..5fd1fccaaf 100644 --- a/apps/web/tests/snapshots/steering/mid-steer.expected.md +++ b/apps/web/tests/snapshots/steering/mid-steer.expected.md @@ -23,7 +23,7 @@ - img - text: Ask question waiting - status: Deep diving... -- text: "Interjection Interjection: include the word BANANA in your final reply." +- text: "Interjection: include the word BANANA in your final reply." - button "Copy": - img - region "Ready to continue?": diff --git a/apps/web/tests/snapshots/steering/settled.expected.md b/apps/web/tests/snapshots/steering/settled.expected.md index 7479c3a8c0..cba73282f3 100644 --- a/apps/web/tests/snapshots/steering/settled.expected.md +++ b/apps/web/tests/snapshots/steering/settled.expected.md @@ -21,7 +21,7 @@ - img - img - text: Ask question 1/1 answered -- text: "Interjection Interjection: include the word BANANA in your final reply. {{clock}}" +- text: "Interjection: include the word BANANA in your final reply. {{clock}}" - button "Copy": - img - button "Think The user selected \"Yes\" and wants me to include the word \"BANANA\" in my final reply. Let me acknowledge their answer.": diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index 324cde1227..fa33e5cd3d 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -446,7 +446,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () = ).toBe(3) expect(await page.getByText('Ungrouped', { exact: true }).count()).toBe(0) const hierarchy = page.getByRole('navigation', { name: 'Session hierarchy' }) - expect(await hierarchy.getByRole('button').count()).toBe(1) + await expect.poll(() => hierarchy.getByRole('button').count()).toBe(1) await compareOrRefreshGolden( FORK_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Sessions"]', scaffold.workspaceCwd), diff --git a/apps/web/tests/todo-row.snapshot.ts b/apps/web/tests/todo-row.snapshot.ts index c5ded143b3..ff43cee39b 100644 --- a/apps/web/tests/todo-row.snapshot.ts +++ b/apps/web/tests/todo-row.snapshot.ts @@ -2,7 +2,7 @@ // Assembled todo snapshot: boots the real built `packages/client/*/lib/ // client.js` bundles through AppWebEntry's ModuleLoader path against the // keyless FixtureApiClient transport, opens the fixture session, and pins the -// two surfaces the fixture's parallel plan (turn 72, two items `in_progress`) +// two surfaces the fixture's parallel plan (turn 73, two items `in_progress`) // reaches — the `todo_write` tool row and the dock's plan strip. // // The row is pinned as three separate fields on purpose. `summary=` is the diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index 085d993fef..d8e6851d04 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.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/capability-seams.md -capability-seams.md: 345e17c8c28bbe3465abd20639770e6331a70d11 -capability-seams.zh.md: 472aaabf4c992fdcd54fbe0e9a14cf9a82e6b803 +capability-seams.md: d5610b6bf6bcb39bfa146ada53ce0734a978b8cd +capability-seams.zh.md: 8a1b38d3600d673a8e0a99941e51d0973101a1c1 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 345e17c8c2..d5610b6bf6 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -7,10 +7,14 @@ A service can be a core spine service, a swappable capability seam, or a bundle/ ```mermaid flowchart LR + pkg_attachment["attachment"] + svc_attachments["ctx.attachments
Durable binary attachment storage"] + pkg_attachment_local["attachment-local"] + pkg_host_runtime["host-runtime"] + pkg_llm_pi_ai["llm-pi-ai"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compact_basic["compact-basic"] @@ -186,6 +190,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval + pkg_attachment --> svc_attachments + pkg_attachment_local --> svc_attachments pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash @@ -280,6 +286,8 @@ flowchart LR svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash svc_approval --> pkg_tools + svc_attachments --> pkg_host_runtime + svc_attachments --> pkg_llm_pi_ai svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash @@ -380,6 +388,7 @@ flowchart LR | ctx key | Role | Owner | Implementations | Direct consumers | Companion plugins | Note | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`, [`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content. | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 472aaabf4c..8a1b38d360 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -9,10 +9,14 @@ ```mermaid flowchart LR + pkg_attachment["attachment"] + svc_attachments["ctx.attachments
Durable binary attachment storage"] + pkg_attachment_local["attachment-local"] + pkg_host_runtime["host-runtime"] + pkg_llm_pi_ai["llm-pi-ai"] pkg_llm["llm"] svc_llm["ctx.llm
LLM adapter registry"] pkg_llm_deepseek["llm-deepseek"] - pkg_llm_pi_ai["llm-pi-ai"] pkg_llm_replay["llm-replay"] pkg_agent_loop["agent-loop"] pkg_compact_basic["compact-basic"] @@ -188,6 +192,8 @@ flowchart LR pkg_agent_presets --> svc_agentPresets pkg_api_gateway --> svc_typertGateway pkg_approval --> svc_approval + pkg_attachment --> svc_attachments + pkg_attachment_local --> svc_attachments pkg_bash --> svc_bash pkg_bash_env --> svc_bashEnv pkg_bash_local --> svc_bash @@ -282,6 +288,8 @@ flowchart LR svc_agents --> pkg_subagent_inprocess svc_approval --> pkg_tool_bash svc_approval --> pkg_tools + svc_attachments --> pkg_host_runtime + svc_attachments --> pkg_llm_pi_ai svc_bash --> pkg_hooks_claude svc_bash --> pkg_hooks_codex svc_bash --> pkg_tool_bash @@ -382,6 +390,7 @@ flowchart LR | ctx 键 | 角色 | 所属包 | 实现 | 直接消费方 | 配套插件 | 说明 | | --- | --- | --- | --- | --- | --- | --- | +| `ctx.attachments` | `seam` | [`attachment`](../packages/attachment/attachment) | [`attachment-local`](../packages/attachment/attachment-local) | `host-runtime`、[`llm-pi-ai`](../packages/llm/llm-pi-ai) | - | 宿主会在会话事件之前提交已接受的图片;提供方适配器将已授权的持久引用解析为提供方原生内容。 | | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 60c6e85cca..cc76a6b557 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 18980d22c694647374b9fa4e6dfbf245ff2416c4 -config-catalog.zh.md: a43c561806498ca53a95af815d0cd7686a0100ca +config-catalog.md: a785f856f0c5e3b1f99260e365ba5d15641dd5be +config-catalog.zh.md: 913f7d7771aa3f5e86b199121c64d5b9b00e968d diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 18980d22c6..a785f856f0 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -261,6 +261,26 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md) Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +## `@deepseek-ai/dsh-attachment-local` + +```ts config-catalog +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} +``` + +Source: [`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -332,10 +352,12 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** Maximum buffered JSON body for every `/api` request. */ + maxRequestBodyBytes?: number } ``` -Source: [`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -617,13 +639,11 @@ Source: [`packages/hooks/hooks-codex/src/index.ts:44`](../packages/hooks/hooks-c ## `@deepseek-ai/dsh-host-apiproxy` -Requires: `agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +Requires: `agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: the Host-only Workspace creation root. */ +/** Gateway plugin config for native Host integration. */ export interface Config { - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string /** * Whether this deployment can hand paths to a native desktop opener — * the `hasDocument` capability the agent-preset roster reports. Absent, @@ -635,7 +655,7 @@ export interface Config { } ``` -Source: [`packages/host/apiproxy/src/index.ts:38`](../packages/host/apiproxy/src/index.ts) +Source: [`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -2736,6 +2756,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)). +- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-bash` — abstract `BashExecutor` ([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — abstract `CompactService` ([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index a43c561806..913f7d7771 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -263,6 +263,26 @@ export interface Config { 来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +## `@deepseek-ai/dsh-attachment-local` + +```ts config-catalog +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} +``` + +来源:[`packages/attachment/attachment-local/src/index.ts:24`](../packages/attachment/attachment-local/src/index.ts) + ## `@deepseek-ai/dsh-bash-env` ```ts config-catalog @@ -334,10 +354,12 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** Maximum buffered JSON body for every `/api` request. */ + maxRequestBodyBytes?: number } ``` -来源:[`packages/client/connection/src/index.ts:32`](../packages/client/connection/src/index.ts) +来源:[`packages/client/connection/src/index.ts:52`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` @@ -619,13 +641,11 @@ export interface Config { ## `@deepseek-ai/dsh-host-apiproxy` -需要:`agentDefaultModel` · `agents` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` +需要:`agentDefaultModel` · `agents` · `attachments` · `directoryPicker` · `llm` · `sessions` · `subagents` · `sessionQuery` · `tools` · `userInteraction` · `workspace` ```ts config-catalog -/** Gateway plugin config: the Host-only Workspace creation root. */ +/** Gateway plugin config for native Host integration. */ export interface Config { - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string /** * Whether this deployment can hand paths to a native desktop opener — * the `hasDocument` capability the agent-preset roster reports. Absent, @@ -637,7 +657,7 @@ export interface Config { } ``` -来源:[`packages/host/apiproxy/src/index.ts:38`](../packages/host/apiproxy/src/index.ts) +来源:[`packages/host/apiproxy/src/index.ts:37`](../packages/host/apiproxy/src/index.ts) ## `@deepseek-ai/dsh-host-directory-picker-browse` @@ -2737,6 +2757,7 @@ export interface Config { 抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md))。 +- `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts)) - `@deepseek-ai/dsh-bash` — 抽象 `BashExecutor`([`packages/bash/bash/src/index.ts`](../packages/bash/bash/src/index.ts)) - `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts)) - `@deepseek-ai/dsh-compact` — 抽象 `CompactService`([`packages/compact/compact/src/index.ts`](../packages/compact/compact/src/index.ts)) diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 2c277a3e72..af0059394b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.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/event-producer-consumer.md -event-producer-consumer.md: bc7ed869e008ef7a16fd0b477fd129dd909cf6af -event-producer-consumer.zh.md: fa9113ae9b6c0640bfc2db20fface0c910449535 +event-producer-consumer.md: 3433a8e1c7278e7190d984e4da27f3baa6419443 +event-producer-consumer.zh.md: 80695b916a29ac1997009cec6ff800afcab28dd0 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index bc7ed869e0..3433a8e1c7 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -28,8 +28,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index fa9113ae9b..80695b916a 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -30,8 +30,8 @@ | `fs/observed` | `emit` | [`packages/fs/fs/src/index.ts:76`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`emit`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`emit`) | [`fs-policy`](../packages/fs/fs-policy), [`skill-local`](../packages/skill/skill-local) | | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:58`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | -| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:73`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | -| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:62`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | +| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | +| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | | `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | | `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 54453b3411..c8cd127319 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: c41db02165740b19a9ef751e6f50316a76df28c3 -module-graph.zh.md: 9071dbc0f2e6df8ec7edd1f3e14cd7ff063123fa +module-graph.md: 9df5eabe1c39fd8bb3e14b635e43b782c816d369 +module-graph.zh.md: ee187222fa1ed727941e7820ac8c33ed532497e3 diff --git a/docs/module-graph.md b/docs/module-graph.md index c41db02165..9df5eabe1c 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -127,6 +127,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -327,9 +331,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -365,34 +368,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_skill --> pkg_scope - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -416,40 +401,36 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants - pkg_lsp --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_type_meta - pkg_skill_badge --> pkg_invariants - pkg_skill_badge --> pkg_skill - pkg_web_fetch_local --> pkg_invariants - pkg_web_fetch_local --> pkg_timeout - pkg_web_fetch_local --> pkg_web - pkg_web_search_exa --> pkg_environment - pkg_web_search_exa --> pkg_invariants - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_environment - pkg_web_search_perplexity --> pkg_invariants - pkg_web_search_perplexity --> pkg_web - pkg_spill --> pkg_brand - pkg_spill --> pkg_invariants - pkg_spill --> pkg_llm - pkg_spill --> pkg_session - pkg_acp_snapshot --> pkg_invariants - pkg_acp_snapshot --> pkg_session - pkg_app_boot --> pkg_environment - pkg_app_boot --> pkg_invariants - pkg_app_boot --> pkg_paths - pkg_app_boot --> pkg_system_prompt + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_skill --> pkg_scope + pkg_web --> pkg_invariants + pkg_web --> pkg_llm pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -480,10 +461,54 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_invariants + pkg_lsp --> pkg_llm + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_type_meta + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill + pkg_web_fetch_local --> pkg_invariants + pkg_web_fetch_local --> pkg_timeout + pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment + pkg_web_search_exa --> pkg_invariants + pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment + pkg_web_search_perplexity --> pkg_invariants + pkg_web_search_perplexity --> pkg_web + pkg_spill --> pkg_brand + pkg_spill --> pkg_invariants + pkg_spill --> pkg_llm + pkg_spill --> pkg_session + pkg_acp_snapshot --> pkg_invariants + pkg_acp_snapshot --> pkg_session + pkg_app_boot --> pkg_environment + pkg_app_boot --> pkg_invariants + pkg_app_boot --> pkg_paths + pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_invariants pkg_agent_presets --> pkg_paths @@ -538,25 +563,15 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -696,10 +711,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1032,6 +1043,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1252,7 +1265,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1267,20 +1280,28 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1289,13 +1310,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1309,11 +1327,9 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1347,7 +1363,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1403,7 +1418,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 9071dbc0f2..ee187222fa 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -129,6 +129,10 @@ flowchart TD pkg_api_gateway["api-gateway"] pkg_api_remotes["api-remotes"] end + subgraph group_attachment["packages/attachment"] + pkg_attachment["attachment"] + pkg_attachment_local["attachment-local"] + end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] end @@ -329,9 +333,8 @@ flowchart TD pkg_type_meta --> pkg_invariants pkg_typert_generator --> pkg_invariants pkg_typert_registry --> pkg_invariants - pkg_llm --> pkg_brand - pkg_llm --> pkg_invariants - pkg_llm --> pkg_timeout + pkg_attachment --> pkg_brand + pkg_attachment --> pkg_invariants pkg_client_connection --> pkg_host_webserver pkg_client_connection --> pkg_invariants pkg_client_hmr --> pkg_client_modules @@ -367,34 +370,16 @@ flowchart TD pkg_subprocess_local --> pkg_timeout pkg_typert_loader --> pkg_invariants pkg_typert_loader --> pkg_typert_registry - pkg_llm_deepseek --> pkg_credentials - pkg_llm_deepseek --> pkg_environment - pkg_llm_deepseek --> pkg_invariants - pkg_llm_deepseek --> pkg_llm - pkg_llm_deepseek --> pkg_settings - pkg_llm_deepseek --> pkg_timeout - pkg_llm_pi_ai --> pkg_credentials - pkg_llm_pi_ai --> pkg_environment - pkg_llm_pi_ai --> pkg_invariants - pkg_llm_pi_ai --> pkg_llm - pkg_llm_pi_ai --> pkg_settings - pkg_llm_pi_ai --> pkg_timeout - pkg_session --> pkg_brand - pkg_session --> pkg_invariants - pkg_session --> pkg_llm - pkg_session --> pkg_scope - pkg_session --> pkg_type_meta - pkg_system_prompt --> pkg_invariants - pkg_system_prompt --> pkg_llm - pkg_system_prompt --> pkg_scope - pkg_skill --> pkg_invariants - pkg_skill --> pkg_llm - pkg_skill --> pkg_scope - pkg_web --> pkg_invariants - pkg_web --> pkg_llm + pkg_llm --> pkg_attachment + pkg_llm --> pkg_brand + pkg_llm --> pkg_invariants + pkg_llm --> pkg_timeout pkg_api_gateway --> pkg_client_connection pkg_api_gateway --> pkg_invariants pkg_api_gateway --> pkg_typert_registry + pkg_attachment_local --> pkg_attachment + pkg_attachment_local --> pkg_invariants + pkg_attachment_local --> pkg_paths pkg_client_locale --> pkg_client_runtime pkg_client_locale --> pkg_client_ui_primitives pkg_client_locale --> pkg_client_ui_slots @@ -418,40 +403,36 @@ flowchart TD pkg_credentials_local --> pkg_environment pkg_credentials_local --> pkg_invariants pkg_credentials_local --> pkg_paths - pkg_lsp --> pkg_brand - pkg_lsp --> pkg_invariants - pkg_lsp --> pkg_llm pkg_settings_local --> pkg_atomic_write pkg_settings_local --> pkg_invariants pkg_settings_local --> pkg_paths pkg_settings_local --> pkg_settings - pkg_agent --> pkg_invariants - pkg_agent --> pkg_llm - pkg_agent --> pkg_scope - pkg_agent --> pkg_session - pkg_agent --> pkg_system_prompt - pkg_agent --> pkg_type_meta - pkg_skill_badge --> pkg_invariants - pkg_skill_badge --> pkg_skill - pkg_web_fetch_local --> pkg_invariants - pkg_web_fetch_local --> pkg_timeout - pkg_web_fetch_local --> pkg_web - pkg_web_search_exa --> pkg_environment - pkg_web_search_exa --> pkg_invariants - pkg_web_search_exa --> pkg_web - pkg_web_search_perplexity --> pkg_environment - pkg_web_search_perplexity --> pkg_invariants - pkg_web_search_perplexity --> pkg_web - pkg_spill --> pkg_brand - pkg_spill --> pkg_invariants - pkg_spill --> pkg_llm - pkg_spill --> pkg_session - pkg_acp_snapshot --> pkg_invariants - pkg_acp_snapshot --> pkg_session - pkg_app_boot --> pkg_environment - pkg_app_boot --> pkg_invariants - pkg_app_boot --> pkg_paths - pkg_app_boot --> pkg_system_prompt + pkg_llm_deepseek --> pkg_credentials + pkg_llm_deepseek --> pkg_environment + pkg_llm_deepseek --> pkg_invariants + pkg_llm_deepseek --> pkg_llm + pkg_llm_deepseek --> pkg_settings + pkg_llm_deepseek --> pkg_timeout + pkg_llm_pi_ai --> pkg_attachment + pkg_llm_pi_ai --> pkg_credentials + pkg_llm_pi_ai --> pkg_environment + pkg_llm_pi_ai --> pkg_invariants + pkg_llm_pi_ai --> pkg_llm + pkg_llm_pi_ai --> pkg_settings + pkg_llm_pi_ai --> pkg_timeout + pkg_session --> pkg_brand + pkg_session --> pkg_invariants + pkg_session --> pkg_llm + pkg_session --> pkg_scope + pkg_session --> pkg_type_meta + pkg_system_prompt --> pkg_invariants + pkg_system_prompt --> pkg_llm + pkg_system_prompt --> pkg_scope + pkg_skill --> pkg_invariants + pkg_skill --> pkg_llm + pkg_skill --> pkg_scope + pkg_web --> pkg_invariants + pkg_web --> pkg_llm pkg_client_ui_question --> pkg_client_locale pkg_client_ui_question --> pkg_invariants pkg_client_ui_settings_general --> pkg_client_connection @@ -482,10 +463,54 @@ flowchart TD pkg_client_ui_workspace --> pkg_client_ui_primitives pkg_client_ui_workspace --> pkg_client_ui_slots pkg_client_ui_workspace --> pkg_invariants + pkg_lsp --> pkg_brand + pkg_lsp --> pkg_invariants + pkg_lsp --> pkg_llm + pkg_agent --> pkg_invariants + pkg_agent --> pkg_llm + pkg_agent --> pkg_scope + pkg_agent --> pkg_session + pkg_agent --> pkg_system_prompt + pkg_agent --> pkg_type_meta + pkg_skill_badge --> pkg_invariants + pkg_skill_badge --> pkg_skill + pkg_web_fetch_local --> pkg_invariants + pkg_web_fetch_local --> pkg_timeout + pkg_web_fetch_local --> pkg_web + pkg_web_search_exa --> pkg_environment + pkg_web_search_exa --> pkg_invariants + pkg_web_search_exa --> pkg_web + pkg_web_search_perplexity --> pkg_environment + pkg_web_search_perplexity --> pkg_invariants + pkg_web_search_perplexity --> pkg_web + pkg_spill --> pkg_brand + pkg_spill --> pkg_invariants + pkg_spill --> pkg_llm + pkg_spill --> pkg_session + pkg_acp_snapshot --> pkg_invariants + pkg_acp_snapshot --> pkg_session + pkg_app_boot --> pkg_environment + pkg_app_boot --> pkg_invariants + pkg_app_boot --> pkg_paths + pkg_app_boot --> pkg_system_prompt + pkg_client_ui_layout --> pkg_client_runtime + pkg_client_ui_layout --> pkg_client_ui_slots + pkg_client_ui_layout --> pkg_client_ui_theme + pkg_client_ui_layout --> pkg_invariants pkg_code_runtime_worker --> pkg_code_runtime pkg_code_runtime_worker --> pkg_invariants pkg_code_runtime_worker --> pkg_session pkg_code_runtime_worker --> pkg_timeout + pkg_host_directory_picker_browse --> pkg_client_locale + pkg_host_directory_picker_browse --> pkg_client_runtime + pkg_host_directory_picker_browse --> pkg_client_ui_primitives + pkg_host_directory_picker_browse --> pkg_client_ui_slots + pkg_host_directory_picker_browse --> pkg_client_ui_workspace + pkg_host_directory_picker_browse --> pkg_invariants + pkg_host_directory_picker_native --> pkg_client_runtime + pkg_host_directory_picker_native --> pkg_client_ui_slots + pkg_host_directory_picker_native --> pkg_client_ui_workspace + pkg_host_directory_picker_native --> pkg_invariants pkg_agent_presets --> pkg_atomic_write pkg_agent_presets --> pkg_invariants pkg_agent_presets --> pkg_paths @@ -540,25 +565,15 @@ flowchart TD pkg_loader_smoke --> pkg_invariants pkg_loader_smoke --> pkg_llm pkg_loader_smoke --> pkg_session - pkg_client_ui_layout --> pkg_client_runtime - pkg_client_ui_layout --> pkg_client_ui_slots - pkg_client_ui_layout --> pkg_client_ui_theme - pkg_client_ui_layout --> pkg_invariants pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session pkg_host_apiproxy --> pkg_agent_presets pkg_host_apiproxy --> pkg_invariants - pkg_host_directory_picker_browse --> pkg_client_locale - pkg_host_directory_picker_browse --> pkg_client_runtime - pkg_host_directory_picker_browse --> pkg_client_ui_primitives - pkg_host_directory_picker_browse --> pkg_client_ui_slots - pkg_host_directory_picker_browse --> pkg_client_ui_workspace - pkg_host_directory_picker_browse --> pkg_invariants - pkg_host_directory_picker_native --> pkg_client_runtime - pkg_host_directory_picker_native --> pkg_client_ui_slots - pkg_host_directory_picker_native --> pkg_client_ui_workspace - pkg_host_directory_picker_native --> pkg_invariants + pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse + pkg_host_directory_picker_auto --> pkg_host_directory_picker_native + pkg_host_directory_picker_auto --> pkg_host_webserver + pkg_host_directory_picker_auto --> pkg_invariants pkg_commands --> pkg_agent pkg_commands --> pkg_brand pkg_commands --> pkg_invariants @@ -698,10 +713,6 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session - pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse - pkg_host_directory_picker_auto --> pkg_host_directory_picker_native - pkg_host_directory_picker_auto --> pkg_host_webserver - pkg_host_directory_picker_auto --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -1034,6 +1045,8 @@ flowchart TD pkg_web_app --> pkg_invariants pkg_web_app --> pkg_system_prompt pkg_client_ui_conversation --> pkg_agent + pkg_client_ui_conversation --> pkg_attachment + pkg_client_ui_conversation --> pkg_brand pkg_client_ui_conversation --> pkg_client_locale pkg_client_ui_conversation --> pkg_client_runtime pkg_client_ui_conversation --> pkg_client_ui_primitives @@ -1224,7 +1237,7 @@ flowchart TD pkg_acp_demo --> pkg_workspace_context ``` -| Package | Group | Depends on | +| 包 | 分组 | 依赖项 | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | @@ -1254,7 +1267,7 @@ flowchart TD | [`type-meta`](../packages/typert/type-meta) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-generator`](../packages/typert/generator) | `typert` | [`invariants`](../packages/support/invariants) | | [`typert-registry`](../packages/typert/registry) | `typert` | [`invariants`](../packages/support/invariants) | -| [`llm`](../packages/llm/llm) | `llm` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | +| [`attachment`](../packages/attachment/attachment) | `attachment` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`client-connection`](../packages/client/connection) | `client` | [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-hmr`](../packages/client/hmr) | `client` | [`client-modules`](../packages/client/modules), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`client-runtime`](../packages/client/runtime) | `client` | [`invariants`](../packages/support/invariants), [`type-meta`](../packages/typert/type-meta), [`typert-registry`](../packages/typert/registry) | @@ -1269,20 +1282,28 @@ flowchart TD | [`storage-sqlite`](../packages/storage/storage-sqlite) | `storage` | [`invariants`](../packages/support/invariants), [`storage`](../packages/storage/storage) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | -| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | -| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | -| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | -| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`llm`](../packages/llm/llm) | `llm` | [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout) | | [`api-gateway`](../packages/api/gateway) | `api` | [`client-connection`](../packages/client/connection), [`invariants`](../packages/support/invariants), [`typert-registry`](../packages/typert/registry) | +| [`attachment-local`](../packages/attachment/attachment-local) | `attachment` | [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | -| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | +| [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`llm-pi-ai`](../packages/llm/llm-pi-ai) | `llm` | [`attachment`](../packages/attachment/attachment), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | +| [`session`](../packages/core/session) | `core` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`type-meta`](../packages/typert/type-meta) | +| [`system-prompt`](../packages/core/system-prompt) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`skill`](../packages/skill/skill) | `skill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope) | +| [`web`](../packages/web/web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | +| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | +| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`lsp`](../packages/lsp/lsp) | `lsp` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`agent`](../packages/core/agent) | `core` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`type-meta`](../packages/typert/type-meta) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`invariants`](../packages/support/invariants), [`skill`](../packages/skill/skill) | | [`web-fetch-local`](../packages/web/web-fetch-local) | `web` | [`invariants`](../packages/support/invariants), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | @@ -1291,13 +1312,10 @@ flowchart TD | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`acp-snapshot`](../packages/support/acp-snapshot) | `support` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`app-boot`](../packages/boot/app-boot) | `boot` | [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-question`](../packages/client/ui-question) | `client` | [`client-locale`](../packages/client/locale), [`invariants`](../packages/support/invariants) | -| [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | -| [`client-ui-sidebar`](../packages/client/ui-sidebar) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-slash`](../packages/client/ui-slash) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-theme`](../packages/client/ui-theme) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-workspace`](../packages/client/ui-workspace) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | +| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1311,11 +1329,9 @@ flowchart TD | [`web-search-deepseek`](../packages/web/web-search-deepseek) | `web` | [`agent`](../packages/core/agent), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`web`](../packages/web/web) | | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-ui-layout`](../packages/client/ui-layout) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-theme`](../packages/client/ui-theme), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | +| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | @@ -1349,7 +1365,6 @@ flowchart TD | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | | [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1405,7 +1420,7 @@ flowchart TD | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`hooks-claude`](../packages/hooks/hooks-claude) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | -| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | +| [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 2379b7d25b..99c0f514c4 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/README.md -README.md: 096fd7de4d2644dac664fac940b6487052115258 -README.zh.md: 6b6758db8f7118a09f6b0998231d3944f44f5052 +README.md: b1b57466feeee7625b0b5de78e5f9ff220ddef32 +README.zh.md: 492051f013def0f196902d1105f84a5644057f3a diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index 096fd7de4d..b1b57466fe 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -24,6 +24,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [tools.md](tools.md) | `ToolDefinition` full fields, the schema DSL, `ToolExecution`/`ToolResult`, tool-presentation UI types, and the guarded execution pipeline | | [user-interaction.md](user-interaction.md) | the UI-backed human question/answer seam: `AskUserQuestionRequest`, answer/options vocabulary, provider API, error taxonomy | | [approval.md](approval.md) | the one-shot user-approval seam: `ApprovalRequest`, `ApprovalOutcome`, per-session policy, audit and answerer contracts | +| [attachment.md](attachment.md) | durable image identity and metadata, validation inputs, verified reads, and the `AttachmentStore` seam | | [bash.md](bash.md) | the bash executor seam: `BashExecRequest`/`Spec`, `BashRunResult`, background `BashProcess` handles | | [subprocess.md](subprocess.md) | the subprocess seam: fully-explicit `SubprocessSpawnSpec`, offset-based output readers, unclassified `SubprocessOutcome`, and the managed `DSH_*` environment vocabulary | | [pty.md](pty.md) | persistent terminal ids, backend/session contracts, send readiness, bounded reads, and owner-visible snapshots | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index 6b6758db8f..492051f013 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -24,6 +24,7 @@ | [tools.md](tools.md) | `ToolDefinition` 完整字段、schema DSL、`ToolExecution`/`ToolResult`、工具展示 UI 类型,以及受保护的执行流水线 | | [user-interaction.md](user-interaction.md) | UI 支持的人工问答 seam:`AskUserQuestionRequest`、answer/options 词汇、提供方 API、错误分类体系 | | [approval.md](approval.md) | 一次性用户审批 seam:`ApprovalRequest`、`ApprovalOutcome`、逐会话策略、审计与 answerer 约定 | +| [attachment.md](attachment.md) | 持久图片标识与元数据、校验输入、经校验读取,以及 `AttachmentStore` seam | | [bash.md](bash.md) | bash 执行器 seam:`BashExecRequest`/`Spec`、`BashRunResult`、后台 `BashProcess` 句柄 | | [subprocess.md](subprocess.md) | 子进程 seam:完全显式的 `SubprocessSpawnSpec`、基于偏移的输出读取器、不含分类的 `SubprocessOutcome`,以及受管 `DSH_*` 环境词汇 | | [pty.md](pty.md) | 持久化终端 ID、后端/会话约定、发送就绪状态、有界读取与 owner 可见快照 | diff --git a/docs/subsystems/attachment.i18n.yaml b/docs/subsystems/attachment.i18n.yaml new file mode 100644 index 0000000000..0d2e75d8fc --- /dev/null +++ b/docs/subsystems/attachment.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 docs/subsystems/attachment.md +attachment.md: 51556c3a0391a571656f407ed22edb8b93eb5326 +attachment.zh.md: 2a6079845ae6f19884b9fb3312e32187161d57d1 diff --git a/docs/subsystems/attachment.md b/docs/subsystems/attachment.md new file mode 100644 index 0000000000..51556c3a03 --- /dev/null +++ b/docs/subsystems/attachment.md @@ -0,0 +1,113 @@ +# Durable Image Attachments + +English | [中文](attachment.zh.md) + +The attachment seam separates binary image ownership from the session log. A producer gives validated encoded bytes to [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam); the service publishes an immutable content-addressed reference only after the object is durable. Session events and model-visible `ImageBlock`s contain that reference and metadata, never a browser object URL, host temporary path, provider URL, or base64 payload. + +Unsent browser drafts may stay in memory and native clients may stage them in operating-system temporary storage. Once the host accepts a user message, its images move below `/attachments/v1` before the user event is appended. Structured model image output follows the same persist-before-event rule. + +Source: [`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) + +## Identity and verified metadata + +`AttachmentId` is a branded opaque string. The local backend currently emits `sha256:`, but consumers must neither parse that representation nor derive a filesystem path from it. + +```ts type-equiv +/** Raster image formats accepted by the version-one attachment path. */ +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' +``` + +```ts type-equiv +/** Durable, serializable metadata for one immutable image object. */ +interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} +``` + +```ts type-equiv +/** Deployment-resolved limits used by upload admission and request buffering. */ +interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} +``` + +The reference records intrinsic dimensions and encoded length so clients can lay out history without decoding first, while every authoritative read still re-checks digest, media signature, dimensions, and metadata against the object. + +## Commit and verified-read payloads + +```ts type-equiv +/** Request to validate and durably commit one image. */ +interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against fully decoded bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} +``` + +```ts type-equiv +/** Stored image bytes returned after reference and digest verification. */ +interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} +``` + +`saveImage()` validates bytes and atomically commits one object before returning its reference. `validateImage()` runs the same admission checks without persisting anything; batch callers validate every member through it before saving any member, so validation rejection leaves no partial objects behind. `readImage()` accepts a reference from an authorized session path and returns bytes only after integrity verification. The service is deliberately retention-neutral: resumed and forked sessions may share objects, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.attachments` — `AttachmentStore` (abstract seam) + +Immutable binary attachment service. Implementations validate bytes before publishing a reference. + +```ts cordis-catalog +/** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. + */ +abstract validateImage(input: SaveImageAttachment): Promise + +/** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ +abstract saveImage(input: SaveImageAttachment): Promise + +/** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ +abstract readImage(ref: ImageAttachmentRef): Promise +``` + +Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) + diff --git a/docs/subsystems/attachment.zh.md b/docs/subsystems/attachment.zh.md new file mode 100644 index 0000000000..2a6079845a --- /dev/null +++ b/docs/subsystems/attachment.zh.md @@ -0,0 +1,113 @@ +# 持久图片附件 + +[English](attachment.md) | 中文 + +附件 seam 将二进制图片的所有权与会话日志分离。生成方把经过校验的编码字节交给 [`ctx.attachments`](#ctxattachments--attachmentstore-abstract-seam);只有对象完成持久化后,该服务才会发布不可变的内容寻址引用。会话事件和模型可见的 `ImageBlock` 包含该引用及其元数据,绝不包含浏览器对象 URL、宿主临时路径、提供方 URL 或 base64 数据。 + +未发送的浏览器草稿可以保留在内存中,原生客户端也可以将其暂存于操作系统临时存储。宿主接受用户消息后,会先把消息中的图片移到 `/attachments/v1` 下,再追加用户事件。结构化模型图片输出遵循同样的先持久化、后追加事件规则。 + +来源:[`packages/attachment/attachment/src/types.ts`](../../packages/attachment/attachment/src/types.ts) + +## 标识与经过校验的元数据 + +`AttachmentId` 是带类型标记的不透明字符串。本地后端目前生成 `sha256:`,但消费方既不能解析这种表示,也不能据此派生文件系统路径。 + +```ts type-equiv +/** Raster image formats accepted by the version-one attachment path. */ +type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' +``` + +```ts type-equiv +/** Durable, serializable metadata for one immutable image object. */ +interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} +``` + +```ts type-equiv +/** Deployment-resolved limits used by upload admission and request buffering. */ +interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} +``` + +引用记录固有尺寸和编码长度,使客户端无需先解码即可排布历史记录;每次权威读取仍会根据对象重新校验摘要、媒体签名、尺寸和元数据。 + +## 提交与校验读取的数据 + +```ts type-equiv +/** Request to validate and durably commit one image. */ +interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against fully decoded bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} +``` + +```ts type-equiv +/** Stored image bytes returned after reference and digest verification. */ +interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} +``` + +`saveImage()` 校验字节并以原子方式提交一个对象,之后才返回其引用。`validateImage()` 执行相同的准入检查,但不持久化任何内容;批量调用方会在保存任何成员前通过它校验所有成员,因此校验拒绝不会留下部分对象。`readImage()` 接受来自已授权会话路径的引用,只在完整性校验通过后返回字节。该服务刻意不规定保留策略:恢复和 fork 后的会话可能共享对象,因此基于引用的垃圾回收会延期实现,而不是与任何一个会话的删除绑定。 + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.attachments` — `AttachmentStore` (abstract seam) + +Immutable binary attachment service. Implementations validate bytes before publishing a reference. + +```ts cordis-catalog +/** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. + */ +abstract validateImage(input: SaveImageAttachment): Promise + +/** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ +abstract saveImage(input: SaveImageAttachment): Promise + +/** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ +abstract readImage(ref: ImageAttachmentRef): Promise +``` + +Source: [`packages/attachment/attachment/src/index.ts:29`](../../packages/attachment/attachment/src/index.ts) + diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 86ec4e3c3f..dfb482ba7e 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 4d450c19ec2bbfacabcefc83466e67c8a6c82bd6 -llm-streaming.zh.md: 777c44eaff2b1ee6e5939e04e580d6ad34b1ad1a +llm-streaming.md: 9f92052d411e3bd4256db63df54eba7f4e313b18 +llm-streaming.zh.md: ab5540c9e7adce70f0462fb478e8f674d1f6bba4 diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 4d450c19ec..9f92052d41 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -22,12 +22,13 @@ Source: [`packages/llm/llm/src/types.ts`](../../packages/llm/llm/src/types.ts) interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. The core set is limited to blocks every shipping path honors — multimodal content (images, audio, …) has no core block type; a feature that needs one adds it via the merge-extensible map together with the adapter/UI/compaction support that honors it. +The block interfaces (full fields in source): `TextBlock` (`text`), `ReasoningBlock` (thinking, distinct from visible text), `ImageBlock` (a durable [image attachment](attachment.md)), `ToolCallBlock` (`id: CallId`, `name`, raw-JSON `arguments`), and `ToolResultBlock` (`toolCallId`, nested `content: ContentBlock[]`, `isError?`). `ContentBlock = ContentBlockMap[ContentBlockType]`. A new modality belongs in the merge-extensible map only when its adapter, UI, compaction, and durable replay paths honor it. Source: [`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) @@ -396,6 +397,8 @@ interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] } ``` @@ -830,7 +833,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts) @@ -855,7 +858,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) @@ -879,5 +882,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 777c44eaff..ab5540c9e7 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -22,12 +22,13 @@ interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } ``` -各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`)、`ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。核心集仅限于每条交付路径都尊重的块——多模态内容(图像、音频等)没有核心块类型;需要的功能通过可合并扩展的 map 添加,同时提供适配器/UI/压缩支持。 +各块接口(完整字段见源码):`TextBlock`(`text`)、`ReasoningBlock`(thinking,区别于可见文本)、`ImageBlock`(一个持久的[图片附件](attachment.md))、`ToolCallBlock`(`id: CallId`、`name`、原始 JSON `arguments`),以及 `ToolResultBlock`(`toolCallId`、嵌套 `content: ContentBlock[]`、`isError?`)。`ContentBlock = ContentBlockMap[ContentBlockType]`。仅当适配器、UI、压缩和持久回放路径均支持某种新模态时,才将其纳入可合并扩展的 map。 源码:[`packages/llm/llm/src/message.ts`](../../packages/llm/llm/src/message.ts) @@ -404,6 +405,8 @@ interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] } ``` @@ -838,7 +841,7 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise ``` -Source: [`packages/llm/llm/src/index.ts:292`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:294`](../../packages/llm/llm/src/index.ts) @@ -863,7 +866,7 @@ The provider topology changed: an adapter registered or unregistered routes, or 'llm/adapters-updated'(): void ``` -Source: [`packages/llm/llm/src/index.ts:73`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:75`](../../packages/llm/llm/src/index.ts) @@ -887,5 +890,5 @@ Waterfall around every streaming model call (retry, replay, routing). Bound to t 'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable): AsyncIterable ``` -Source: [`packages/llm/llm/src/index.ts:62`](../../packages/llm/llm/src/index.ts) +Source: [`packages/llm/llm/src/index.ts:64`](../../packages/llm/llm/src/index.ts) diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6d339a4512..6fa938c18e 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"2ec5ca51-ec8b-4756-8c71-c20fb871b421"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/knip.json b/knip.json index 51eb575f5a..249d21d857 100644 --- a/knip.json +++ b/knip.json @@ -30,9 +30,6 @@ "scripts/**/*.ts", "scripts/**/*.mjs", "scripts/**/*.cjs" - ], - "ignoreDependencies": [ - "playwright" ] }, "examples": { @@ -310,6 +307,11 @@ "src/**/*.ts" ] }, + "packages/attachment/attachment": { + "project": [ + "src/**/*.ts" + ] + }, "packages/util/timeout": { "entry": [ "tests/**/*.spec.ts" diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index b3d68bc8f3..d77d9ed870 100644 --- a/packages/README.i18n.yaml +++ b/packages/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/README.md -README.md: c18a46b7131f7782be68f3c96fa99b89615de471 -README.zh.md: 3cd766ed70b7847bef8229a48b65873540365851 +README.md: c246534a26cd7297f2ba8885099c8b517a5dc2b0 +README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706 diff --git a/packages/README.md b/packages/README.md index c18a46b713..c246534a26 100644 --- a/packages/README.md +++ b/packages/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Packages use the `@deepseek-ai/dsh-*` scope. Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Authoring rules: [package](AGENTS.md) and [root](../AGENTS.md#conventions). +npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). ## Hierarchy @@ -31,13 +31,14 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`tasks/`](tasks/README.md) | Generic background-task runtime and model-facing `task_*` control tools | Product — stable surface | | [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable surface | | [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable surface | +| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable surface | | [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable surface | | [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable surface | | [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable surface | | [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | -| [`self-modification/`](self-modification/README.md) | The agent modifies its own runtime: inspect the live runtime's plugins and services, mount/unmount model-written plugins ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) and restricted repository Plugin loading | Product — stable surface | +| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | diff --git a/packages/README.zh.md b/packages/README.zh.md index 3cd766ed70..4139874d3e 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有包都使用 `@deepseek-ai/dsh-*` scope。Cordis `Service` 子类和函数插件的贡献通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。编写规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 +npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 ## 层级结构 @@ -31,13 +31,14 @@ | [`tasks/`](tasks/README.md) | 通用后台任务运行时和面向模型的 `task_*` 控制工具 | 产品:稳定接口 | | [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定接口 | | [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定接口 | +| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定接口 | | [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定接口 | | [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定接口 | | [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定接口 | | [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | -| [`self-modification/`](self-modification/README.md) | agent 修改自身运行时:检查实时运行时的插件与服务,挂载/卸载模型所写插件([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)),以及受限仓库插件加载 | 产品:稳定接口 | +| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查、模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md))、受限仓库插件加载 | 产品:稳定接口 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 | | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 | diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index a1d37e2aa9..ce00fb105a 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -166,6 +166,17 @@ export function apply(ctx: Context, config: AcpConfig): void { content: { type: 'text', text: block.text }, }, }) + } else if (block.type === 'image') { + notify({ + sessionId: record.agent.session.id, + update: { + sessionUpdate: 'agent_message_chunk', + content: { + type: 'text', + text: `[image attachment ${block.attachment.attachmentId}]`, + }, + }, + }) } } } diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index f2cffb4010..848d112215 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -41,6 +41,35 @@ describe('ACP prompt lifecycle', () => { await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) + it('renders an assistant image as an explicit attachment placeholder', async () => { + const attachmentId = `sha256:${'a'.repeat(64)}` as never + harness = await makeBridgeHarness({ + script: [[ + { type: 'block-start', index: 0, blockType: 'image' }, + { + type: 'block-end', + index: 0, + block: { + type: 'image', + attachment: { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + }, + { type: 'finish', reason: { kind: 'stop' } }, + ]], + }) + const sessionId = await newSession(harness) + await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) + await vi.waitFor(() => { + expect(messageText(harness!)).toBe(`[image attachment ${String(attachmentId)}]`) + }) + }) + it('rejects a failed turn and never publishes its partial chunks', async () => { harness = await makeBridgeHarness({ script: [errorResponse('provider boom')] }) const sessionId = await newSession(harness) diff --git a/packages/attachment/README.i18n.yaml b/packages/attachment/README.i18n.yaml new file mode 100644 index 0000000000..47eaecee0a --- /dev/null +++ b/packages/attachment/README.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 packages/attachment/README.md +README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8 +README.zh.md: b4a80917e166a3b0836e6e9c79d536d9dd8e1a41 diff --git a/packages/attachment/README.md b/packages/attachment/README.md new file mode 100644 index 0000000000..61b4e5c602 --- /dev/null +++ b/packages/attachment/README.md @@ -0,0 +1,12 @@ +# attachment/ - durable attachment capability family + +English | [中文](README.zh.md) + +The durable binary attachment seam and its local filesystem implementation. Both are product packages. + +| Package | Role | ctx key | +|---|---|---| +| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` | +| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) | + +Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output. diff --git a/packages/attachment/README.zh.md b/packages/attachment/README.zh.md new file mode 100644 index 0000000000..b4a80917e1 --- /dev/null +++ b/packages/attachment/README.zh.md @@ -0,0 +1,12 @@ +# attachment/:持久附件能力族 + +[English](README.md) | 中文 + +持久二进制附件服务边界及其本地文件系统实现。两者均为产品包(package)。 + +| 包 | 角色 | ctx 键 | +|---|---|---| +| `attachment/` | 不可变附件引用、图片限制和存储服务 | `ctx.attachments` | +| `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments`) | + +未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。 diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml new file mode 100644 index 0000000000..daa65c2d38 --- /dev/null +++ b/packages/attachment/attachment-local/README.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 packages/attachment/attachment-local/README.md +README.md: 80001b29b392fe1c8b663f46d47f1ec0726e6d0f +README.zh.md: c3b95ace06b9f5ada156f20f33a1740a235400aa diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md new file mode 100644 index 0000000000..80001b29b3 --- /dev/null +++ b/packages/attachment/attachment-local/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-attachment-local + +English | [中文](README.zh.md) + +The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte and pixel limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. + +`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. + +## Model Experience + +Indirectly, through durable replay of historical user images and structured model image output after restart and fork. + +#### KV Cache effect + +None beyond the image block owned by the requesting adapter. + +## Known Limitations and Deferred Work + +- Objects are retained indefinitely; reference-aware garbage collection is deferred. +- The local backend assumes the host and provider adapter share this filesystem service. +- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned. diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md new file mode 100644 index 0000000000..c3b95ace06 --- /dev/null +++ b/packages/attachment/attachment-local/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-attachment-local + +[English](README.md) | 中文 + +这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节和像素限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。 + +`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。 + +## 模型体验 + +该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。 + +#### KV 缓存影响 + +除发起请求的适配器所持有的图片块外,不产生其他影响。 + +## 已知限制与待完成工作 + +- 对象会无限期保留;基于引用的垃圾回收尚未实现。 +- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 +- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。 diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json new file mode 100644 index 0000000000..1834ef2d90 --- /dev/null +++ b/packages/attachment/attachment-local/package.json @@ -0,0 +1,33 @@ +{ + "name": "@deepseek-ai/dsh-attachment-local", + "description": "Private content-addressed DSH_HOME attachment storage", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-paths": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "dependencies": { + "schemastery": "^3.18.0", + "sharp": "^0.35.3" + }, + "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts new file mode 100644 index 0000000000..e06bf459df --- /dev/null +++ b/packages/attachment/attachment-local/src/image.ts @@ -0,0 +1,66 @@ +/** Raster inspection: full decode at admission, header-only probe on verified reads. */ + +import sharp, { type Sharp } from 'sharp' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' + +/** Decoded metadata from a supported image. */ +export interface DetectedImage { + mediaType: ImageMediaType + width: number + height: number +} + +const MEDIA_TYPES: Readonly> = { + png: 'image/png', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', +} + +async function imageMetadata(image: Sharp): Promise { + const metadata = await image.metadata() + const mediaType = MEDIA_TYPES[metadata.format as string] + if (mediaType === undefined) { + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') + } + return { mediaType, width: metadata.width, height: metadata.height } +} + +/** + * Parse a supported raster's header and return its intrinsic metadata without + * decoding pixels. Digest-verified reads use this: admission already proved + * that these exact bytes decode completely, so the read path only re-derives + * the reference fields instead of paying the full-raster decode again. + * @param data - complete encoded image bytes. + * @returns verified format and dimensions. + */ +export async function probeImage(data: Uint8Array): Promise { + try { + return await imageMetadata(sharp(data, { failOn: 'error', limitInputPixels: false })) + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) + } +} + +/** + * Fully decode a supported raster and return its intrinsic metadata. + * @param data - complete encoded image bytes. + * @param maxPixels - decoded-pixel admission limit. + * @returns verified format and dimensions. + */ +export async function detectImage(data: Uint8Array, maxPixels?: number): Promise { + try { + const image = sharp(data, { failOn: 'error', limitInputPixels: false }) + const detected = await imageMetadata(image) + if (maxPixels !== undefined && detected.width * detected.height > maxPixels) { + throw new AttachmentError('Image exceeds the configured decoded-pixel limit.', 'IMAGE_TOO_MANY_PIXELS') + } + await image.raw().toBuffer() + return detected + } catch (error) { + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE', { cause: error }) + } +} diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts new file mode 100644 index 0000000000..7ed4824ef2 --- /dev/null +++ b/packages/attachment/attachment-local/src/index.ts @@ -0,0 +1,76 @@ +/** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ + +import { join, resolve } from 'node:path' +import { Context } from 'cordis' +import z from 'schemastery' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { readImageFile, saveImageFile, validateImageFile } from './store.ts' + +export { detectImage } from './image.ts' +export { readImageFile, saveImageFile, validateImageFile } from './store.ts' + +/** Default maximum encoded bytes for one image. */ +export const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024 +/** Default maximum images in one prompt. */ +export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 10 +/** Default maximum aggregate image bytes in one prompt. */ +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 20 * 1024 * 1024 +/** Default maximum intrinsic pixels for one image. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 + +/** Local attachment backend configuration. */ +export interface Config { + /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Maximum encoded bytes accepted for one image. */ + maxImageBytes?: number + /** Maximum image count accepted in one submitted message. */ + maxImagesPerMessage?: number + /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + maxMessageImageBytes?: number + /** Maximum intrinsic width multiplied by height accepted for one image. */ + maxImagePixels?: number +} + +/** Persistent content-addressed local attachment store. */ +export class LocalAttachmentStore extends AttachmentStore { + static Config: z = z.object({ + dshHome: z.string(), + maxImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_BYTES), + maxImagesPerMessage: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGES_PER_MESSAGE), + maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), + maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), + }) + + /** Absolute versioned storage root. */ + readonly root: string + readonly imageLimits: ImageAttachmentLimits + + constructor(ctx: Context, config: Config) { + super(ctx) + this.root = resolve(join(resolveDshHome(config.dshHome), 'attachments', 'v1')) + this.imageLimits = Object.freeze({ + maxImageBytes: config.maxImageBytes ?? DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: config.maxImagesPerMessage ?? DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: config.maxMessageImageBytes ?? DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: config.maxImagePixels ?? DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), + }) + } + + async validateImage(input: SaveImageAttachment): Promise { + await validateImageFile(input, this.imageLimits) + } + + async saveImage(input: SaveImageAttachment): Promise { + return saveImageFile(this.root, input, this.imageLimits) + } + + async readImage(ref: ImageAttachmentRef): Promise { + return readImageFile(this.root, ref) + } +} + +export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts new file mode 100644 index 0000000000..eb14a84af6 --- /dev/null +++ b/packages/attachment/attachment-local/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local' +/** Cordis companion plugin name. */ +export const name = 'attachment-local-invariant' +/** Services required before package ownership can be reserved. */ +export const inject = ['invariants', 'attachments'] +/** No runtime invariant: immutable writes and verified reads are enforced directly at the backend boundary. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts new file mode 100644 index 0000000000..d77f2be375 --- /dev/null +++ b/packages/attachment/attachment-local/src/store.ts @@ -0,0 +1,221 @@ +/** Content-addressed, owner-private local attachment storage. */ + +import { createHash, randomUUID } from 'node:crypto' +import { constants } from 'node:fs' +import { chmod, link, mkdir, open, readFile, unlink } from 'node:fs/promises' +import { dirname, join, parse, resolve } from 'node:path' +import { + AttachmentError, + AttachmentId, +} from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { detectImage, probeImage } from './image.ts' + +const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ +const durableHomes = new Set() + +function digest(data: Uint8Array): string { + return createHash('sha256').update(data).digest('hex') +} + +function displayName(value: string | undefined): string | undefined { + if (value === undefined) return undefined + // Strip both separator styles by hand: a POSIX host treats `\` as an + // ordinary character, so path.basename would keep a Windows client's full + // local path and leak it into the reference and the session log. + const leaf = value.slice(Math.max(value.lastIndexOf('/'), value.lastIndexOf('\\')) + 1) + const clean = leaf.replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 255) + return clean === '' ? undefined : clean +} + +function objectPath(root: string, sha256: string): string { + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + +function ensureReference(ref: ImageAttachmentRef): string { + const match = ID_PATTERN.exec(String(ref.attachmentId)) + if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') + return match[1] +} + +async function inspectMetadata( + data: Uint8Array, + declaredMediaType: ImageAttachmentRef['mediaType'], + maxPixels?: number, +): Promise> { + if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') + const detected = await detectImage(data, maxPixels) + if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') + return { ...detected, bytes: data.byteLength } +} + +/** + * Run the full admission policy for one image without touching storage. + * @param input - encoded bytes and declared metadata. + * @param limits - resolved storage policy. + * @returns completion after the encoded raster has been fully decoded. + */ +export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { + if (input.data.byteLength > limits.maxImageBytes) { + throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + } + await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) +} + +/** + * Make a directory's entries durable (fsync on a read-only directory handle). + * A synced file alone does not survive a crash when its directory entry never + * reached storage, so the publication directory is synced before a durable + * reference is reported. + */ +async function syncDirectory(path: string): Promise { + /* v8 ignore next -- Windows cannot open directory handles; NTFS metadata journaling owns entry durability there. */ + if (process.platform === 'win32') return + /* v8 ignore start -- Windows cannot exercise directory fsync; POSIX behavior tests enforce this peer. */ + const handle = await open(path, constants.O_RDONLY) + try { + await handle.sync() + } finally { + await handle.close() + } + /* v8 ignore stop */ +} + +/** + * Create one private directory tree and persist every ancestor entry up to a + * caller-vouched durable boundary. The walk deliberately ignores what mkdir + * reports as newly created: a concurrent first save can create a level this + * process then merely observes, so "already existed" is not "already durable" + * — the entry may still be unsynced in the creator, and a crash would drop a + * directory the session checkpoint already references. Re-syncing a durable + * entry is harmless; skipping an unsynced one is not. + * @param path - absolute directory to create. + * @param boundary - absolute ancestor the caller vouches is already durable. + */ +async function ensureDurableDirectory(path: string, boundary: string): Promise { + const target = resolve(path) + const stop = resolve(boundary) + await mkdir(target, { recursive: true, mode: 0o700 }) + await chmod(target, 0o700) + let level = target + while (level !== stop) { + const parent = dirname(level) + await syncDirectory(parent) + /* v8 ignore next -- filesystem-root guard: callers pass a boundary that is an ancestor of path, so the walk reaches it first. */ + if (parent === level) return + level = parent + } +} + +/** + * Establish this process's proof that one DSH_HOME entry and every ancestor + * below the filesystem root are durable. Mere existence is insufficient: a + * concurrent process may have created the directory but not synced its parent. + */ +async function ensureDurableHome(path: string): Promise { + const home = resolve(path) + if (!durableHomes.has(home)) { + await ensureDurableDirectory(home, parse(home).root) + durableHomes.add(home) + } + return home +} + +/** + * Save and verify immutable image bytes below a versioned attachment root. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - encoded bytes and declared metadata. + * @param limits - resolved storage policy. + * @returns durable content-addressed reference. + */ +export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { + if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') + const metadata = await inspectMetadata(input.data, input.mediaType, limits.maxImagePixels) + const sha256 = digest(input.data) + const bucket = join(root, 'objects', sha256.slice(0, 2)) + const staging = join(root, 'tmp') + // Establish DSH_HOME itself against the filesystem root once per process. + // Every process performs that proof independently, so observing a directory + // another process created can never be mistaken for durable publication. + const boundary = await ensureDurableHome(dirname(dirname(resolve(root)))) + await ensureDurableDirectory(bucket, boundary) + await ensureDurableDirectory(staging, boundary) + const temporary = join(staging, randomUUID()) + const target = objectPath(root, sha256) + let handle + try { + handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) + await handle.writeFile(input.data) + await handle.sync() + await handle.close() + handle = undefined + try { + await link(temporary, target) + } catch (error) { + /* v8 ignore next -- Private same-filesystem directories make EEXIST the only recoverable link race. */ + if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error + const existing = new Uint8Array(await readFile(target)) + if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + } + // Persist the target entry and close a concurrent bucket-creation window + // before the reference can reach a session checkpoint. The dedup path + // repeats both syncs because it may observe another writer's link before + // that writer reaches its own durability boundary. + await syncDirectory(bucket) + await syncDirectory(join(root, 'objects')) + await unlink(temporary) + } catch (error) { + /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ + if (handle !== undefined) await handle.close().catch( + /* v8 ignore next -- Close failure is superseded by the storage operation that entered cleanup. */ + () => {}, + ) + await unlink(temporary).catch( + /* v8 ignore next -- The callback requires a second independent staging-unlink failure. */ + (cleanupError: unknown) => { + /* v8 ignore next -- Cleanup is best-effort only for a staging file already removed by a failed operation. */ + if (!(cleanupError instanceof Error && 'code' in cleanupError && cleanupError.code === 'ENOENT')) throw cleanupError + }, + ) + if (error instanceof AttachmentError) throw error + throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) + } + const name = displayName(input.name) + return { + attachmentId: AttachmentId(`sha256:${sha256}`), + ...metadata, + ...(name !== undefined ? { name } : {}), + } +} + +/** + * Read and verify one content-addressed image. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - reference recorded in the session log. + * @returns verified bytes and reference. + */ +export async function readImageFile(root: string, ref: ImageAttachmentRef): Promise { + const sha256 = ensureReference(ref) + let data: Uint8Array + try { + data = new Uint8Array(await readFile(objectPath(root, sha256))) + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') + throw new AttachmentError('Unable to read image attachment.', 'ATTACHMENT_READ_FAILED', { cause: error }) + } + if (digest(data) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') + // The digest proves these are the exact bytes admission fully decoded, so + // the read path only re-derives the header fields (no raster decode, no + // per-request pixel amplification on history replay). + const metadata = await probeImage(data) + if (metadata.mediaType !== ref.mediaType || data.byteLength !== ref.bytes + || metadata.width !== ref.width || metadata.height !== ref.height) { + throw new AttachmentError('Stored attachment metadata does not match its reference.', 'ATTACHMENT_CORRUPT') + } + return { ref, data } +} diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts new file mode 100644 index 0000000000..72a258e2bd --- /dev/null +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -0,0 +1,51 @@ +import sharp from 'sharp' +import { describe, expect, it } from 'vitest' +import { detectImage, probeImage } from '../src/image.ts' + +async function raster(format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { + const image = sharp({ + create: { width: 3, height: 2, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 1 } }, + }) + return new Uint8Array(await image.toFormat(format).toBuffer()) +} + +describe('raster decoding', () => { + it('decodes every supported format and its intrinsic dimensions', async () => { + for (const [format, mediaType] of [ + ['png', 'image/png'], + ['jpeg', 'image/jpeg'], + ['webp', 'image/webp'], + ['gif', 'image/gif'], + ] as const) { + await expect(detectImage(await raster(format))) + .resolves.toEqual({ mediaType, width: 3, height: 2 }) + } + }) + + it('rejects excess decoded pixels before decoding', async () => { + await expect(detectImage(await raster('png'), 5)) + .rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + }) + + it('rejects malformed bytes and truncated payloads with readable headers', async () => { + await expect(detectImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(detectImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const complete = await raster('png') + const truncated = complete.subarray(0, 62) + await expect(sharp(truncated).metadata()).resolves.toMatchObject({ width: 3, height: 2 }) + await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }) + + it('probes malformed bytes and unsupported formats into the same stable error', async () => { + await expect(probeImage(Uint8Array.of(1, 2, 3))) + .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + const unsupported = await sharp({ + create: { width: 1, height: 1, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).tiff().toBuffer() + await expect(probeImage(unsupported)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts new file mode 100644 index 0000000000..8aad68d2ff --- /dev/null +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -0,0 +1,60 @@ +import { Context } from 'cordis' +import { existsSync } from 'node:fs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import LocalAttachmentStore, { + DEFAULT_MAX_IMAGE_BYTES, + DEFAULT_MAX_IMAGE_PIXELS, + DEFAULT_MAX_IMAGES_PER_MESSAGE, + DEFAULT_MAX_MESSAGE_IMAGE_BYTES, +} from '../src/index.ts' + +describe('local attachment service', () => { + it('resolves every omitted admission limit explicitly', () => { + const service = new LocalAttachmentStore(new Context(), {}) + expect(service.imageLimits).toEqual({ + maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, + maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, + maxMessageImageBytes: DEFAULT_MAX_MESSAGE_IMAGE_BYTES, + maxImagePixels: DEFAULT_MAX_IMAGE_PIXELS, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], + }) + }) + + it('saves and reads through the service boundary', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-service-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const data = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + const ref = await service.saveImage({ data, mediaType: 'image/png' }) + await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + + it('validates without persisting: a rejected image leaves no storage root behind', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-validate-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' })) + .rejects.toThrow(/Unsupported or malformed image data/) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + )) + const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 }) + await expect(limited.validateImage({ data: valid, mediaType: 'image/png' })) + .rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + await expect(service.validateImage({ data: valid, mediaType: 'image/png' })).resolves.toBeUndefined() + expect(existsSync(service.root)).toBe(false) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) +}) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts new file mode 100644 index 0000000000..bd2adb4c55 --- /dev/null +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -0,0 +1,208 @@ +import { createHash } from 'node:crypto' +import { constants } from 'node:fs' +import { chmod, mkdir, readFile, stat, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, parse, resolve } from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' +import sharp from 'sharp' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import { readImageFile, saveImageFile } from '../src/store.ts' + +const fsControl = vi.hoisted(() => ({ syncedDirectories: [] as string[] })) + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async open(...args: Parameters): ReturnType { + if (args[1] === constants.O_RDONLY) fsControl.syncedDirectories.push(String(args[0])) + return actual.open(...args) + }, + } +}) + +const PNG = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', +)) + +const LIMITS: ImageAttachmentLimits = { + maxImageBytes: 1024, + maxImagesPerMessage: 2, + maxMessageImageBytes: 2048, + maxImagePixels: 16, + mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], +} + +const roots: string[] = [] + +async function root(): Promise { + const value = await mkdtemp(join(tmpdir(), 'dsh-attachment-')) + roots.push(value) + return join(value, 'attachments', 'v1') +} + +function parentChainToRoot(path: string): string[] { + const parents: string[] = [] + let level = resolve(path) + const root = parse(level).root + while (level !== root) { + level = dirname(level) + parents.push(level) + } + return parents +} + +afterEach(async () => { + await Promise.all(roots.splice(0).map(path => rm(path, { recursive: true, force: true }))) +}) + +describe('local attachment store', () => { + it.skipIf(process.platform === 'win32')('syncs every object ancestor up to the durable boundary before returning', async () => { + const storageRoot = await root() + const base = join(storageRoot, '..', '..') + const sha256 = createHash('sha256').update(PNG).digest('hex') + const objects = join(storageRoot, 'objects') + const bucket = join(objects, sha256.slice(0, 2)) + fsControl.syncedDirectories.length = 0 + + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + // Each process first proves DSH_HOME durable all the way to the filesystem + // root; existence alone cannot vouch for a concurrent creator's fsync. + // Later directory creation can then stop at that process-proven boundary. + expect(fsControl.syncedDirectories).toEqual([ + ...parentChainToRoot(base), + // bucket chain: every parent entry between the bucket and the boundary. + objects, + storageRoot, + join(storageRoot, '..'), + base, + // staging chain re-walks the shared ancestors after creating tmp. + storageRoot, + join(storageRoot, '..'), + base, + // publication: the settled object's bucket and its parent for the rename. + bucket, + objects, + ]) + }) + + it('creates and persists a missing nested home directory against the filesystem root', async () => { + const storageRoot = join(await root(), 'home', 'attachments', 'v1') + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + + it('publishes one private content-addressed object and deduplicates equal bytes', async () => { + const storageRoot = await root() + const first = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png', + }, LIMITS) + const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = createHash('sha256').update(PNG).digest('hex') + const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + + expect(first).toEqual({ + attachmentId: `sha256:${sha256}`, + mediaType: 'image/png', + bytes: PNG.byteLength, + width: 1, + height: 1, + name: 'pixel.png', + }) + expect(second.attachmentId).toBe(first.attachmentId) + expect(new Uint8Array(await readFile(object))).toEqual(PNG) + if (process.platform !== 'win32') { + expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) + } + await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) + }) + + it('keeps admitted history readable after deployment limits become stricter', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + + it('rejects malformed bytes, mismatched declarations, byte limits, and decoded-pixel limits', async () => { + const storageRoot = await root() + await expect(saveImageFile(storageRoot, { + data: new Uint8Array(0), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + await expect(saveImageFile(storageRoot, { + data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/jpeg', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) + await expect(saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', + }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + + const wide = new Uint8Array(await sharp({ + create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, + }).png().toBuffer()) + await expect(saveImageFile(storageRoot, { + data: wide, mediaType: 'image/png', + }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + const unnamed = await saveImageFile(storageRoot, { + data: PNG, mediaType: 'image/png', name: '\u0000', + }, LIMITS) + expect(unnamed).not.toHaveProperty('name') + }) + + it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { + const storageRoot = await root() + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const sha256 = String(ref.attachmentId).slice('sha256:'.length) + const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await chmod(object, 0o600) + await writeFile(object, Uint8Array.of(1, 2, 3)) + await expect(readImageFile(storageRoot, ref)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + await expect(readImageFile(storageRoot, { ...ref, attachmentId: 'bad' as never })) + .rejects.toMatchObject({ code: 'INVALID_ATTACHMENT_REF' }) + + const missingRoot = await root() + await mkdir(missingRoot, { recursive: true }) + await expect(readImageFile(missingRoot, ref)) + .rejects.toMatchObject({ code: 'ATTACHMENT_NOT_FOUND' }) + + const unreadableRoot = await root() + const target = join(unreadableRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + await expect(readImageFile(unreadableRoot, ref)) + .rejects.toMatchObject({ code: 'ATTACHMENT_READ_FAILED' }) + }) + + it('rejects conflicting existing objects and reference metadata mismatches', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true }) + await writeFile(target, Uint8Array.of(1, 2, 3)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + + await writeFile(target, PNG) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) + .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) + + it('maps unexpected publication failures to a stable storage error', async () => { + const storageRoot = await root() + const sha256 = createHash('sha256').update(PNG).digest('hex') + const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) + await mkdir(target, { recursive: true }) + + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) + }) +}) diff --git a/packages/attachment/attachment-local/tsconfig.json b/packages/attachment/attachment-local/tsconfig.json new file mode 100644 index 0000000000..528649e3a4 --- /dev/null +++ b/packages/attachment/attachment-local/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../attachment" }, + { "path": "../../util/paths" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml new file mode 100644 index 0000000000..c75c93eb1a --- /dev/null +++ b/packages/attachment/attachment/README.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 packages/attachment/attachment/README.md +README.md: 4f450316294e554396adb9a8454051a08d9befd3 +README.zh.md: fe51b0003cdf1659c7c56106b97c6f3139ebe890 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md new file mode 100644 index 0000000000..4f45031629 --- /dev/null +++ b/packages/attachment/attachment/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-attachment + +English | [中文](README.zh.md) + +The durable attachment seam. `ctx.attachments` validates and atomically commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. + +Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting; batch writers validate every member first so a malformed member cannot strand earlier members as unreferenced objects. `saveImage` commits each accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. + +## Model Experience + +Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. + +#### KV Cache effect + +Adding an image changes the provider request and therefore invalidates the affected request suffix. + +## Known Limitations and Deferred Work + +- Version one accepts PNG, JPEG, WebP, and GIF only. +- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects. +- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts. diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md new file mode 100644 index 0000000000..fe51b0003c --- /dev/null +++ b/packages/attachment/attachment/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-attachment + +[English](README.md) | 中文 + +持久附件服务边界。`ctx.attachments` 校验并以原子方式提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 + +未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化;批量写入方会先校验每个成员,避免某个格式错误的成员使较早的成员成为无引用对象。`saveImage` 会在发布任何模型可见的会话事件前提交每张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。 + +## 模型体验 + +该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。 + +#### KV 缓存影响 + +添加图片会改变提供方请求,因此会使受影响的请求后缀失效。 + +## 已知限制与待完成工作 + +- 第一版仅接受 PNG、JPEG、WebP 和 GIF。 +- 保留策略与垃圾回收尚未实现,因为恢复和 fork 后的会话可能共享不可变对象。 +- 通用文件、音频、视频和持久的未发送草稿需要单独的生命周期与提供方契约。 diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json new file mode 100644 index 0000000000..66cecc1894 --- /dev/null +++ b/packages/attachment/attachment/package.json @@ -0,0 +1,27 @@ +{ + "name": "@deepseek-ai/dsh-attachment", + "description": "Durable immutable attachment storage seam for the DeepSeek Harness", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/attachment/attachment/src/brand.ts b/packages/attachment/attachment/src/brand.ts new file mode 100644 index 0000000000..6df4014f74 --- /dev/null +++ b/packages/attachment/attachment/src/brand.ts @@ -0,0 +1,15 @@ +/** Attachment identifier brand. @module @deepseek-ai/dsh-attachment/brand */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Opaque content-addressed identifier for one immutable attachment object. */ +export type AttachmentId = Branded<'AttachmentId'> + +/** + * Brand a validated storage identifier. + * @param value - backend-produced opaque identifier. + * @returns the branded identifier. + */ +export function AttachmentId(value: string): AttachmentId { + return value as AttachmentId +} diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts new file mode 100644 index 0000000000..827d77f58a --- /dev/null +++ b/packages/attachment/attachment/src/error.ts @@ -0,0 +1,26 @@ +/** Attachment failure class. @module @deepseek-ai/dsh-attachment/error */ + +/** + * Stable failures suitable for host RPC error mapping. + * + * Deliberately re-implements the `HarnessError` shape instead of extending it: + * the base lives in `@deepseek-ai/dsh-llm`, which itself depends on this + * package (`ImageBlock` references `ImageAttachmentRef`), so sharing the base + * would create a dependency cycle. Consumers route on `code`, never on the + * prototype chain, so the shapes stay interchangeable at the wire boundary. + */ +export class AttachmentError extends Error { + /** Stable machine-routing failure code. */ + readonly code: string + + /** + * @param message - human-readable failure description without raw bytes or host paths. + * @param code - stable machine-routing code. + * @param options - optional chained cause. + */ + constructor(message: string, code: string, options?: ErrorOptions) { + super(message, options) + this.name = 'AttachmentError' + this.code = code + } +} diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts new file mode 100644 index 0000000000..9b3b8dd92b --- /dev/null +++ b/packages/attachment/attachment/src/index.ts @@ -0,0 +1,60 @@ +/** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ + +import { Context, Service } from 'cordis' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +export { AttachmentId } from './brand.ts' +export { AttachmentError } from './error.ts' +export type { + AttachmentId as AttachmentIdType, + ImageAttachmentLimits, + ImageAttachmentRef, + ImageMediaType, + SaveImageAttachment, + StoredImageAttachment, +} from './types.ts' + +declare module 'cordis' { + interface Context { + attachments: AttachmentStore + } +} + +/** Immutable binary attachment service. Implementations validate bytes before publishing a reference. */ +export abstract class AttachmentStore extends Service { + constructor(ctx: Context) { + super(ctx, 'attachments') + } + + /** Deployment-resolved image policy used by authoritative and fast-path validation. */ + abstract readonly imageLimits: ImageAttachmentLimits + + /** + * Validate one image without persisting it. + * Batch callers validate every member before saving any member. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns completion after the encoded raster has been fully decoded. + */ + abstract validateImage(input: SaveImageAttachment): Promise + + /** + * Validate and durably commit one image before its owning session event is appended. + * @param input - encoded bytes, declared media type, and optional display name. + * @returns a durable content-addressed reference. + */ + abstract saveImage(input: SaveImageAttachment): Promise + + /** + * Read one image and verify that bytes still match the recorded reference. + * @param ref - durable reference from the session log. + * @returns the verified bytes and canonical reference. + */ + abstract readImage(ref: ImageAttachmentRef): Promise +} + +export default AttachmentStore diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts new file mode 100644 index 0000000000..2c00d56ece --- /dev/null +++ b/packages/attachment/attachment/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-attachment' +/** Cordis companion plugin name. */ +export const name = 'attachment-invariant' +/** Service required before package ownership can be reserved. */ +export const inject = ['invariants'] +/** No runtime invariant: this stateless seam owns types while implementations enforce immutable-store checks. */ +const install: InvariantInstaller = () => {} +/** + * Register the package invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the registration disposer. + */ +export const apply = (ctx: Context): Promise<() => void> => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts new file mode 100644 index 0000000000..102209553b --- /dev/null +++ b/packages/attachment/attachment/src/types.ts @@ -0,0 +1,48 @@ +/** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ + +import type { AttachmentId } from './brand.ts' + +export type { AttachmentId } from './brand.ts' + +/** Raster image formats accepted by the version-one attachment path. */ +export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + +/** Durable, serializable metadata for one immutable image object. */ +export interface ImageAttachmentRef { + /** Opaque storage identifier; never a filesystem path or bearer URL. */ + attachmentId: AttachmentId + /** Media type verified from the stored bytes. */ + mediaType: ImageMediaType + /** Exact encoded byte length. */ + bytes: number + /** Intrinsic encoded width in pixels. */ + width: number + /** Intrinsic encoded height in pixels. */ + height: number + /** Optional display name stripped of local path information. */ + name?: string +} + +/** Deployment-resolved limits used by upload admission and request buffering. */ +export interface ImageAttachmentLimits { + maxImageBytes: number + maxImagesPerMessage: number + maxMessageImageBytes: number + maxImagePixels: number + mediaTypes: readonly ImageMediaType[] +} + +/** Request to validate and durably commit one image. */ +export interface SaveImageAttachment { + data: Uint8Array + /** Caller-declared media type, checked against fully decoded bytes. */ + mediaType: ImageMediaType + /** Optional browser/provider display name; it is never interpreted as a path. */ + name?: string +} + +/** Stored image bytes returned after reference and digest verification. */ +export interface StoredImageAttachment { + ref: ImageAttachmentRef + data: Uint8Array +} diff --git a/packages/attachment/attachment/tsconfig.json b/packages/attachment/attachment/tsconfig.json new file mode 100644 index 0000000000..e9edd57b9e --- /dev/null +++ b/packages/attachment/attachment/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib/types" }, + "include": ["src"], + "references": [ + { "path": "../../../vendor/cosmokit" }, + { "path": "../../../vendor/cordis" }, + { "path": "../../util/brand" }, + { "path": "../../support/invariants" } + ] +} diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 977afa8863..400fa766d7 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -107,6 +107,12 @@ config: root: !!js dshHomePath('sessions') + # Durable image bytes live outside the append-only session log. Messages + # keep content-addressed references that this shared backend resolves for + # provider requests and authorized history reads. + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + # Raw configs can supply a process-local path or disable this shared session # capability. The neutral default is process-local and opens only when used. - id: session-query-sqlite diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index fe03bdc7e3..4713dfa216 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -39,6 +39,7 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-attachment-local": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-command-compact": "workspace:^", diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index e4c4935a2a..d75971516e 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -4,7 +4,7 @@ # # A patch replaces the targeted row's whole `config`, so each row below # restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--workspace-root/--trusted-host into further patches over these rows +# --dev/--trusted-host into further patches over these rows # (`--dev` inserts the dsh-client-hmr row). # ── surface-specific values the base deliberately omits ───────────────────── diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 929464fdc8..baafbd4456 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -29,6 +29,7 @@ }, "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 7e9471bff8..a92db49899 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -6,10 +6,10 @@ // The ./api and ./client subpath exports are the browser-safe channels. export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, - WorkspaceApi, WorkspaceId, WorkspaceView, + ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, CommandsApi, CommandDescriptor, SkillsApi, SkillEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, @@ -37,6 +37,9 @@ export type { SessionId, SessionEvent } from '@deepseek-ai/dsh-session/types' export type { MessageId } from '@deepseek-ai/dsh-llm/brand' export type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm/types' +/** Successful value returned by the connection-generation host handshake. */ +export type HostDescription = import('@deepseek-ai/dsh-host-apiproxy/api').ResponseValue<'host.describe'> + import type { RpcResponse, RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' /** diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index cfa608a394..12202bf4b5 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -131,11 +131,15 @@ export class ConnectionController { // subscribed baseline. The timeout guards against a carrier that never fires onOpen // (see ConnectionConfig.streamOpenTimeoutMs). const timeout = new AbortController() - await Promise.all([ + const [description] = await Promise.all([ this.api.host.describe({}), Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), ]) timeout.abort() + const descriptionResult = description.result + if (!descriptionResult.ok) { + throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) + } if (ac.signal.aborted) throw new Error('generation aborted during readiness handshake') this.attempt = 0 this.emitState('connected') diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 70ce89677c..f149e28984 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,7 +1,7 @@ // FixtureApi: standalone UI development without a server. Real contract shape: unary takes // RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest // (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse -// and returns RpcReceipt. fx-alpha carries a hand-built history script (60 turns, pageable); +// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable); // prompt triggers a chunked streaming replay; cancel stops the replay; resident pending // approval/question requests exercise replay and composer takeover with stable rpcIds. @@ -19,6 +19,7 @@ import type { ToolResultMessage, UserMessage, } from '@deepseek-ai/dsh-llm' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent, SessionId, @@ -51,10 +52,10 @@ function userMessage(content: ContentBlock[], source: MessageSource = { kind: 'u return createUserMessage({ content, source }) } -function assistantMessage(content: ContentBlock[]): AssistantMessage { +function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMessage { return createAssistantMessage({ content, - source: { provider: 'fixture', model: 'fx-1' }, + source: { provider: 'fixture', model }, }) } @@ -330,6 +331,16 @@ function sid(id: string): SessionId { return id as SessionId } +const FIXTURE_IMAGE_DATA = 'iVBORw0KGgoAAAANSUhEUgAAAKAAAABaCAYAAAA/xl1SAAAAvklEQVR42u3SMQ0AAAjAMIyhELM4AAe8PD1qYFlk9cCXEAEDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGBANiQDAgBgQDYkAwIAYEA2JAMCAGxIBCYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIBgQAwIBsSAYEAMCAbEgGBADAgGxIAYEAyIAcGAGBAMiAHBgBgQDIgBwYAYEAyIAcGAGBAMiAHBgBgQDIgB4bYWLb6pnOb1xAAAAABJRU5ErkJggg==' +const FIXTURE_IMAGE_REF: ImageAttachmentRef = { + attachmentId: 'fixture:image' as AttachmentIdType, + mediaType: 'image/png', + bytes: 247, + width: 160, + height: 90, + name: 'fixture-image.png', +} + /** Deterministic provider billing attached to fixture assistant messages. */ function fixtureUsage(turn: number, step: number): TokenUsage { return { @@ -340,7 +351,7 @@ function fixtureUsage(turn: number, step: number): TokenUsage { } } -/** fx-alpha history script: 60 turns (~130+ messages -> 3 pages at PAGE_MESSAGES=50), +/** fx-alpha history script: 74 turns (~150+ messages -> 4 pages at PAGE_MESSAGES=50), * mixing reasoning blocks / tool call+result / context. */ function buildAlphaLog(): SessionEvent[] { const events: Record[] = [] @@ -476,7 +487,7 @@ function buildAlphaLog(): SessionEvent[] { push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Turn 72: todo_write sample — the TodoRow toolview in the flow plus the + // Turn 73: todo_write sample — the TodoRow toolview in the flow plus the // todo/write snapshot event feeding the TodoPanel plan strip. Two items are // in_progress: this fixture chooses the parallel policy, so both surfaces // must render a parallel plan rather than the first active item alone. @@ -535,8 +546,32 @@ function buildAlphaLog(): SessionEvent[] { toolTurn(70, 'web_search', '{"query":"deepseek harness architecture"}', 'Search results for deepseek harness architecture.') toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + // Turn 72: user and assistant images share one durable fixture object. + // The todo turn remains last so its standing projection stays visible. + push({ type: 'turn/start', data: { turn: 72 } }) + push({ + type: 'user/message', + surfaceOp: 'append', + data: userMessage([{ type: 'image', attachment: FIXTURE_IMAGE_REF }, ...text('历史用户图片')]), + }) + push({ type: 'step/start', data: { turn: 72, step: 0 } }) + push({ + type: 'assistant/message', + surfaceOp: 'append', + data: { + turn: 72, + step: 0, + message: assistantMessage( + [...text('结构化模型图片:'), { type: 'image', attachment: FIXTURE_IMAGE_REF }], + 'fx-vision', + ), + }, + }) + push({ type: 'step/end', data: { turn: 72, step: 0 } }) + push({ type: 'turn/end', data: { turn: 72, reason: { kind: 'completed' } } }) + const todoArgs = JSON.stringify({ todos: fixtureTodos }) - toolTurn(72, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') + toolTurn(73, 'todo_write', todoArgs, 'Updated todo list: 1 pending, 2 in progress, 1 completed.') // The real tool appends the snapshot mid-execution — between tool/call and // tool/result — so the fixture reproduces that exact ordering (the last // toolTurn events run ... tool/call, tool/result, step/end, turn/end). @@ -855,7 +890,6 @@ function estimateFixtureContent(blocks: readonly ContentBlock[]): number { // ContentBlockMap is merge-extensible: this client graph sees only the // base four members, but fixture turns do carry extended blocks at // runtime, so the structural JSON fallback below is live code. - // oxlint-disable-next-line typescript/no-unnecessary-condition -- the type collapses without the out-of-graph merges (see above). if (block.type === 'tool-result') { return tokens + estimateFixtureContent(block.content) + BLOCK_OVERHEAD } @@ -1057,6 +1091,18 @@ function pageOf( return { events, hasMore: start > 0 } } +/** Fixture mirror of host session-scoped attachment authorization. */ +function logReferencesAttachment(log: readonly SessionEvent[], attachmentId: string): boolean { + const visit = (value: unknown): boolean => { + if (Array.isArray(value)) return value.some(visit) + if (typeof value !== 'object' || value === null) return false + const record = value as Record + if (record.attachmentId === attachmentId) return true + return Object.values(record).some(visit) + } + return log.some(event => visit(event.data)) +} + /** Fixture mirror of first-party message extraction used by session-query. */ function searchBlockText(block: ContentBlock): string[] { switch (block.type) { @@ -1351,6 +1397,10 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { session.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, ])) + const attachments = new Map([[ + String(FIXTURE_IMAGE_REF.attachmentId), + { attachment: FIXTURE_IMAGE_REF, data: FIXTURE_IMAGE_DATA }, + ]]) /** Credential store double: set/unset flip the describe badge, values never read back. */ const fixtureCredentials = new Map([ // The assembled fixture represents an already-configured shipped @@ -1368,7 +1418,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ['my-agent', { trust: 'user', content: "- id: tool-read\n name: '@deepseek-ai/dsh-tool-read'\n" }], ]) let fixtureDefaultPreset = 'standard' - const nextTurn = new Map([[sid('fx-alpha'), 60]]) + const nextTurn = new Map([[sid('fx-alpha'), 74]]) let nextSession = 1 let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 @@ -2145,9 +2195,26 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // First accepted prompt appends events: the summary stops being blank. summary.blank = false const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max( + 1, + Math.floor(block.data.length * 3 / 4) + - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), + ), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, + } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) if (mode === 'steer' && replays.has(id)) { // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) return ok(request, { accepted: true as const }) } const turn = nextTurn.get(id) ?? 0 @@ -2160,7 +2227,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { if (plan.wanted !== null && plan.wanted !== plan.active) { append(id, { type: 'plan/mode', data: { active: plan.wanted } }) } - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(content) }) + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) // Capacity parallel of the host token-meter's request/context record: // log-only, appended inside the open turn, and deduplicated against the // route already recorded (the fixture never varies contextWindow). @@ -2186,6 +2253,27 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { ) return ok(request, { accepted: true as const }) }, + attachment: (request) => { + const stored = attachments.get(String(request.payload.attachmentId)) + if (stored === undefined) { + return err(request, { + code: 'attachment-error', + message: 'fixture attachment missing', + details: { reason: 'ATTACHMENT_NOT_FOUND' }, + }) + } + if (!logReferencesAttachment( + logs.get(request.payload.sessionId) ?? [], + String(request.payload.attachmentId), + )) { + return err(request, { + code: 'attachment-error', + message: 'fixture attachment is not referenced by this session', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + return ok(request, stored) + }, updateQueue: request => err(request, { code: 'queue-item-not-found', message: 'fixture has no pending queue item', @@ -2262,15 +2350,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { archivedSessionIds: [...archivedSessionIds], }), create: (request) => { - const { path, name } = request.payload - const target = path ?? `/tmp/fixture-workspaces/${name ?? ''}` - const existing = workspaces.find(w => w.path === target) + const { path } = request.payload + const existing = workspaces.find(w => w.path === path) if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) const now = new Date().toISOString() const created: WorkspaceView = { workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path: target, - title: name ?? target.split('/').filter(Boolean).at(-1) ?? target, + path, + title: path.split('/').filter(Boolean).at(-1) ?? path, sessionIds: [], createdAt: now, updatedAt: now, @@ -2837,6 +2924,7 @@ export class FixtureApiClient extends AbstractApiClient { case 'session.rename': return this.api.sessions.rename(request) case 'session.fork': return this.api.sessions.fork(request) case 'session.prompt': return this.api.sessions.prompt(request) + case 'session.attachment': return this.api.sessions.attachment(request) case 'session.updateQueue': return this.api.sessions.updateQueue(request) case 'session.cancel': return this.api.sessions.cancel(request) case 'subagent.list': return this.api.subagents.list(request) diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index e14a0764a8..0788e4508e 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -14,7 +14,7 @@ import type { ClientConnectionRpc } from '../rpc.ts' // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, HostApi, EventsApi, MuxFrame, HostFrame, + ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, DirectoryEntry, DirectoryListing, ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, @@ -24,7 +24,7 @@ export type { SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, - IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, + HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index cdf8d12bfe..9c9e739987 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -21,8 +21,14 @@ export interface FetchHandler { * @param req - incoming node:http request (fully read before dispatch). * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. + * @param maxRequestBodyBytes - maximum body bytes buffered before dispatch. */ -export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandler: FetchHandler): Promise { +export async function bridge( + req: IncomingMessage, + res: ServerResponse, + apiHandler: FetchHandler, + maxRequestBodyBytes = 32 * 1024 * 1024, +): Promise { const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is @@ -32,8 +38,26 @@ export async function bridge(req: IncomingMessage, res: ServerResponse, apiHandl res.on('close', () => { if (!res.writableEnded) abort.abort() }) + const declaredLength = req.headers['content-length'] + if (declaredLength !== undefined && Number(declaredLength) > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } const chunks: Buffer[] = [] - for await (const chunk of req) chunks.push(chunk as Buffer) + let received = 0 + for await (const chunk of req) { + const buffer = chunk as Buffer + received += buffer.byteLength + if (received > maxRequestBodyBytes) { + res.writeHead(413, { connection: 'close' }) + res.end() + req.destroy() + return + } + chunks.push(buffer) + } /* v8 ignore next 3 -- `??` arms: node:http always sets url/method on server requests; the fields are only optional on the client-side IncomingMessage type */ const request = new Request(new URL(req.url ?? '/', 'http://dsh.internal'), { diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f865653b9f..b2189501cb 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,6 +1,7 @@ /** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' import z from 'schemastery' +import type {} from '@deepseek-ai/dsh-attachment' // Activates the httpServer Context merge used below. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' @@ -25,6 +26,25 @@ export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' +/** Headroom for RPC JSON fields around aggregate base64 image payloads. */ +const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024 + +function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): void { + const attachments = ctx.get('attachments') + if (attachments === undefined) return + const requiredImageBodyBytes = Math.ceil( + attachments.imageLimits.maxMessageImageBytes * 4 / 3, + ) + REQUEST_ENVELOPE_HEADROOM_BYTES + if (maxRequestBodyBytes < requiredImageBodyBytes) { + throw new Error( + `client-connection maxRequestBodyBytes (${String(maxRequestBodyBytes)}) must be at least ` + + `${String(requiredImageBodyBytes)} for the configured aggregate image limit`, + ) + } +} +/** Default carrier cap for all HTTP RPC bodies. */ +const DEFAULT_MAX_REQUEST_BODY_BYTES = 32 * 1024 * 1024 + /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ export const inject = ['httpServer'] @@ -39,10 +59,13 @@ export interface ConnectionConfig { * that is not a bare, canonical authority fails the plugin load. */ trustedHosts?: string[] + /** Maximum buffered JSON body for every `/api` request. */ + maxRequestBodyBytes?: number } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), + maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), }) /** @@ -109,9 +132,11 @@ const PRIVILEGED_METHODS = new Set([ export function apply(ctx: Context, config?: ConnectionConfig): void { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] + const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) + if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes) const connection = new HostConnectionService(ctx, trustedHosts) const fetchHandler = connection.createSharedFetchHandler(API_PATH, { async fetch(request) { @@ -144,11 +169,12 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { res.end('forbidden') return } - await bridge(req, res, fetchHandler) + await bridge(req, res, fetchHandler, maxRequestBodyBytes) }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') ctx.inject(['apiProxy'], (apiCtx) => { + assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) const registerDownlink = ( path: string, diff --git a/packages/client/connection/tests/connection.spec.ts b/packages/client/connection/tests/connection.spec.ts index 4de4a31f25..26efec982a 100644 --- a/packages/client/connection/tests/connection.spec.ts +++ b/packages/client/connection/tests/connection.spec.ts @@ -83,6 +83,35 @@ describe('connection lifecycle', () => { } }) + it('treats a host.describe business error as generation failure', async () => { + const api = new FakeApiClient() + let describeCalls = 0 + api.onDescribe = () => { + describeCalls += 1 + if (describeCalls === 1) { + return Promise.resolve({ + rpcId: 'bad-describe' as never, + result: { + ok: false as const, + error: { code: 'internal' as const, message: 'not ready', details: {} }, + }, + }) + } + return Promise.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0 })) + } + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + controller.start() + try { + await vi.waitFor(() => { expect(describeCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) + } finally { + controller.stop() + warnSpy.mockRestore() + } + }) + it('converges stream/error frames into reconnect instead of dispatching them', async () => { const api = new FakeApiClient() const muxSeen: string[] = [] diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index fc6ba9a57d..f1e62c618a 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/packages/client/connection/tests/fake-api.ts @@ -67,6 +67,8 @@ export class FakeApiClient implements IApiClient { => Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise> = @@ -110,6 +112,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/connection/tests/fixture.spec.ts b/packages/client/connection/tests/fixture.spec.ts index 53b68ca786..b7d4d12e8d 100644 --- a/packages/client/connection/tests/fixture.spec.ts +++ b/packages/client/connection/tests/fixture.spec.ts @@ -535,7 +535,7 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create by name mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { const api = createFixtureApi() const abort = new AbortController() const seen: HostFrame[] = [] @@ -546,7 +546,7 @@ describe('createFixtureApi', () => { } })() await new Promise(resolve => setTimeout(resolve, 10)) - const created = await api.workspace.create(req({ name: 'nova' })) + const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') expect(created.result.value.created).toBe(true) expect(created.result.value.workspace).toMatchObject({ @@ -554,16 +554,7 @@ describe('createFixtureApi', () => { }) await consuming expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) - // path spelling falls back to the basename when no title/name rides along. - const pathOnly = await api.workspace.create(req({ path: '/tmp/fixture-elsewhere/base' })) - if (!pathOnly.result.ok) throw new Error('pathOnly failed') - expect(pathOnly.result.value.workspace.title).toBe('base') - // Degenerate spellings reach the impl unfiltered (the fixture carrier has - // no schema gate): both-absent falls back to the bucket dir, and a - // basename-less path serves as its own title. - const bare = await api.workspace.create(req({})) - if (!bare.result.ok) throw new Error('bare failed') - expect(bare.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/', title: 'fixture-workspaces' }) + // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') expect(rootPath.result.value.workspace.title).toBe('/') @@ -584,7 +575,7 @@ describe('createFixtureApi', () => { const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) - await api.workspace.create(req({ name: 'occupied' })) + await api.workspace.create(req({ path: '/tmp/fixture-workspaces/occupied' })) const conflict = await api.workspace.rename(req({ workspaceId: wsid, title: ' occupied ' })) expect(conflict.result).toMatchObject({ ok: false, error: { code: 'workspace-name-conflict', details: { name: 'occupied' } } }) @@ -722,7 +713,7 @@ describe('createFixtureApi', () => { expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) - const made = await api.workspace.create(req({ name: 'nova' })) + const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) @@ -991,7 +982,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ name: 'via-client' }) + const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId @@ -1049,7 +1040,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { }) const client = new FixtureApiClient() await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ name: 'query-workspace' }) + const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') const abort = new AbortController() const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) diff --git a/packages/client/connection/tests/http-bridge.spec.ts b/packages/client/connection/tests/http-bridge.spec.ts index b06834e523..1b45ecc014 100644 --- a/packages/client/connection/tests/http-bridge.spec.ts +++ b/packages/client/connection/tests/http-bridge.spec.ts @@ -5,6 +5,34 @@ import { describe, expect, it } from 'vitest' import { bridge } from '../src/http-bridge.ts' describe('HTTP bridge abort', () => { + it('destroys a declared-oversize request instead of draining it', async () => { + const destroyed: true[] = [] + const request = Readable.from([]) as unknown as IncomingMessage + Object.assign(request, { + url: '/api/session.prompt', + method: 'POST', + headers: { 'content-type': 'application/json', 'content-length': '999999' }, + destroy: () => { destroyed.push(true) }, + }) + let status: number | undefined + let headers: unknown + const response = Object.assign(new EventEmitter(), { + writableEnded: false, + writeHead(code: number, values?: unknown) { status = code; headers = values; return this }, + write() { return true }, + end(this: { writableEnded: boolean }) { this.writableEnded = true; return this }, + }) as unknown as ServerResponse + + await bridge(request, response, { + fetch: () => { throw new Error('a rejected request must never reach the handler') }, + }, 1000) + // The socket must not stay parked draining a body the client can trickle + // at will after the rejection — same discipline as the chunked overrun. + expect(status).toBe(413) + expect(headers).toMatchObject({ connection: 'close' }) + expect(destroyed).toHaveLength(1) + }) + it('aborts a pending native picker request when the browser disconnects', async () => { const body = JSON.stringify({ type: 'client-request', rpcId: 'picker-1', method: 'host.pickDirectory', payload: {}, @@ -38,7 +66,7 @@ describe('HTTP bridge abort', () => { } return Response.json({ aborted: fetchRequest.signal.aborted }) }, - }) + }, Number.MAX_SAFE_INTEGER) await started response.emit('close') await pending diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index e3a4d6cb26..fc69e4d7a1 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' @@ -89,6 +90,19 @@ async function mounted(config?: { trustedHosts?: string[] }): Promise<{ } describe('connection node half', () => { + it('fails loud when the carrier cap cannot hold the configured image batch', () => { + const ctx = new Context() + const routes: WebRoute[] = [] + ctx.provide('httpServer', fakeHttpServer(routes, []) as HttpServerService) + ctx.provide('attachments', { + imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, + } as AttachmentStore) + ctx.provide('apiProxy', {} as ApiProxy) + expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) }) + .toThrow(/must be at least .* aggregate image limit/) + expect(routes).toHaveLength(0) + }) + it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] diff --git a/packages/client/connection/tsconfig.json b/packages/client/connection/tsconfig.json index 58c04d18bd..7bf86ffefe 100644 --- a/packages/client/connection/tsconfig.json +++ b/packages/client/connection/tsconfig.json @@ -9,6 +9,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 83a62693cb..5aaf492eae 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -33,6 +33,7 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", diff --git a/packages/client/runtime/src/client/contract/session.ts b/packages/client/runtime/src/client/contract/session.ts index 0e21e6ae06..95bff54915 100644 --- a/packages/client/runtime/src/client/contract/session.ts +++ b/packages/client/runtime/src/client/contract/session.ts @@ -7,9 +7,9 @@ * must stub); runtime-internal entry points (history staging, wire-frame * dispatch) stay on the class, invisible out here. */ -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - MessageId, QueueAction, RpcResult, SessionId, + MessageId, PromptContentPart, QueueAction, RpcResult, SessionId, } from '@deepseek-ai/dsh-client-connection/client' import type { ConversationSnapshot } from '../sessions/conversation.ts' import type { ObservableSnapshot } from './store.ts' @@ -33,11 +33,19 @@ export interface ISession { readonly projections: ProjectionsFace /** * Send a prompt into the session. - * @param content - model-facing content blocks. + * @param content - text plus browser-owned temporary image uploads. * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. * @returns acceptance, or the business error (also mirrored into snapshot.promptError). */ - prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> + prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> + /** + * Resolve one durable image referenced by this session. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + readAttachment( + attachmentId: AttachmentIdType, + ): Promise> /** * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. * @param itemId - agent-owned inbox occurrence identity. diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..ad896bbdaf 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -27,11 +27,11 @@ export interface IWorkspaces { */ startSession(workspaceId?: WorkspaceId): void /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - create(input: { name: string } | { path: string }): Promise + create(input: { path: string }): Promise /** * Open the Host's native directory picker. * @returns the selected path, or null when the user cancelled. diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index 628fe89aa8..a14d6fbb96 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -8,6 +8,7 @@ import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { LlmRetryEventData } from '@deepseek-ai/dsh-llm-retry/types' import type { TodoItem } from '@deepseek-ai/dsh-session/types' import type { @@ -43,6 +44,7 @@ export interface AssistantProvenanceView { export type AssistantBlock = | { kind: 'text'; text: string } | { kind: 'reasoning'; text: string } + | { kind: 'image'; attachment: ImageAttachmentRef } | { kind: 'tool-call'; callId: string; name: string; argsRaw: string } | { kind: 'other'; block: unknown } @@ -64,6 +66,7 @@ export function toAssistantBlock(block: ContentBlock): AssistantBlock { switch (block.type) { case 'text': return { kind: 'text', text: block.text } case 'reasoning': return { kind: 'reasoning', text: block.text } + case 'image': return { kind: 'image', attachment: block.attachment } case 'tool-call': return { kind: 'tool-call', callId: String(block.id), name: block.name, argsRaw: block.arguments } default: return { kind: 'other', block } } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 1f351c9c4b..b073bc7ef0 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,10 +1,10 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. import type { Context } from 'cordis' -import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { - HistoryEntry, IApiClient, MessageId, MuxFrame, QueueAction, RpcError, + HistoryEntry, IApiClient, MessageId, MuxFrame, PromptContentPart, QueueAction, RpcError, RpcId, RpcResponse, RpcResult, SessionId, SubagentAddress, ToolEventView, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): @@ -178,11 +178,11 @@ export class Session implements SessionFace { /** * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. - * @param content - core content blocks verbatim. + * @param content - text plus browser-owned temporary image uploads. * @param mode - queue appends after the current turn; steer interrupts it. * @returns the prompt result (also mirrored into promptError on failure). */ - async prompt(content: ContentBlock[], mode: 'queue' | 'steer'): Promise> { + async prompt(content: PromptContentPart[], mode: 'queue' | 'steer'): Promise> { this.promptError = null this.lastAgentError = null // Synchronous, before the first await: the blank → engaging edge must be @@ -205,8 +205,24 @@ export class Session implements SessionFace { }, } } else { - const routed = (await this.api.subagents.prompt({ ...this.address, content })).result - result = routed.ok ? { ok: true, value: { accepted: true } } : routed + if (content.some(part => part.type === 'image')) { + result = { + ok: false, + error: { + code: 'attachment-error', + message: 'Image input is unavailable for subagent continuations.', + details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + }, + } + } else { + const routed = (await this.api.subagents.prompt({ + ...this.address, + content: content.flatMap(part => part.type === 'text' + ? [{ type: 'text' as const, text: part.text }] + : []), + })).result + result = routed.ok ? { ok: true, value: { accepted: true } } : routed + } } } catch (error) { result = transportError(error) @@ -232,6 +248,28 @@ export class Session implements SessionFace { return result } + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment( + attachmentId: AttachmentIdType, + ): Promise> { + try { + const result = (await this.api.sessions.attachment({ + sessionId: this.sessionId, + attachmentId, + })).result + if (!result.ok) return result + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } + } catch (error) { + return transportError(error) + } + } + /** Apply one operation to a still-pending queue occurrence. */ async updateQueue(itemId: MessageId, action: QueueAction): Promise> { try { diff --git a/packages/client/runtime/src/client/workspaces/manager.ts b/packages/client/runtime/src/client/workspaces/manager.ts index ccf0c46fe1..df3aa8fe28 100644 --- a/packages/client/runtime/src/client/workspaces/manager.ts +++ b/packages/client/runtime/src/client/workspaces/manager.ts @@ -120,7 +120,7 @@ export class WorkspaceManager { /** * Create or resolve a real Workspace, then publish its returned snapshot * without waiting for the changed frame. - * @param input - name under workspaceRoot or an existing absolute path. + * @param input - the existing absolute path to adopt. * @returns the wire result. */ async create(input: WorkspaceCreateInput): Promise> { diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 69910fd0c4..c0f71b92db 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -186,11 +186,11 @@ export class WorkspacesService implements IWorkspaces { } /** - * Create a Workspace by name or register an existing path. - * @param input - exactly one Host create spelling. + * Register an existing path as a Workspace. + * @param input - the Host create payload. * @returns the created or idempotently resolved Workspace. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { const result = await this.manager.create(input) if (!result.ok) throw new WorkspaceCreateError(result.error) return result.value.workspace diff --git a/packages/client/runtime/src/client/workspaces/workspace.ts b/packages/client/runtime/src/client/workspaces/workspace.ts index e1e280d1c6..6d24534c6c 100644 --- a/packages/client/runtime/src/client/workspaces/workspace.ts +++ b/packages/client/runtime/src/client/workspaces/workspace.ts @@ -8,7 +8,7 @@ import type { ObservableSnapshot } from '../contract/store.ts' import { Notifier } from '../sessions/notifier.ts' /** Host input retained by a local Workspace until materialization succeeds. */ -export type WorkspaceCreateInput = { name: string } | { path: string } +export type WorkspaceCreateInput = { path: string } /** Observable state of a client-local Workspace intent. */ export interface WorkspaceIntentSnapshot { @@ -137,7 +137,6 @@ export class Workspace implements ObservableSnapshot { } function intentName(input: WorkspaceCreateInput): string { - if ('name' in input) return input.name const trimmed = input.path.replace(/[\\/]+$/, '') return trimmed.split(/[\\/]/).pop() ?? input.path } diff --git a/packages/client/runtime/tests/conversation.spec.ts b/packages/client/runtime/tests/conversation.spec.ts index 7da20bc6f3..345f21b598 100644 --- a/packages/client/runtime/tests/conversation.spec.ts +++ b/packages/client/runtime/tests/conversation.spec.ts @@ -1,22 +1,30 @@ /** Assistant block classifier (moved here with sessions/conversation.ts). */ import { describe, expect, it } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-client-connection/client' import { toAssistantBlock, toAssistantBlocks } from '../src/client/sessions/conversation.ts' describe('toAssistantBlock', () => { it('classifies the four block shapes', () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 1, + height: 1, + } const blocks: ContentBlock[] = [ { type: 'text', text: '正文' }, { type: 'reasoning', text: '思考' }, { type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' } as ContentBlock, - { type: 'image', data: 'x' } as unknown as ContentBlock, + { type: 'image', attachment }, ] expect(toAssistantBlocks(blocks)).toEqual([ { kind: 'text', text: '正文' }, { kind: 'reasoning', text: '思考' }, { kind: 'tool-call', callId: 'c1', name: 'echo', argsRaw: '{}' }, - { kind: 'other', block: blocks[3] }, + { kind: 'image', attachment }, ]) expect(toAssistantBlock(blocks[0] as ContentBlock)).toEqual({ kind: 'text', text: '正文' }) }) diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index 860936bd77..da3c1d3025 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/packages/client/runtime/tests/fake-api.ts @@ -85,6 +85,8 @@ export class FakeApiClient implements IApiClient { Promise> = payload => Promise.resolve(ok({ selected: { provider: payload.provider, model: payload.model } })) onPrompt: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + onAttachment: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, data: 'AA==' })) onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) @@ -129,6 +131,7 @@ export class FakeApiClient implements IApiClient { rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), + attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), } diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 02df08de35..36d9ce1b3d 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -531,6 +531,21 @@ describe('prompt and cancel errors', () => { expect(result.ok).toBe(false) expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) }) + + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { + const { api, session } = makeSession() + const result = await session.readAttachment('attachment-1' as never) + expect(result).toEqual({ + ok: true, + value: { + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: Uint8Array.of(0), + }, + }) + expect(api.callsOf('session.attachment')).toEqual([{ + sessionId: SID, attachmentId: 'attachment-1', + }]) + }) }) describe('rename', () => { diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 832a1ff71a..dd38a3119c 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -59,7 +59,7 @@ describe('WorkspaceManager', () => { expect(manager.getSnapshot()).toMatchObject({ phase: 'ready', state: 'error', error: { message: 'wire down' } }) }) - it('creates by name/path, prepends a new row, and folds failures', async () => { + it('creates by path, prepends a new row, and folds failures', async () => { const api = new FakeApiClient() const manager = new WorkspaceManager(api) api.onWorkspaceCreate = payload => Promise.resolve(ok({ @@ -67,8 +67,8 @@ describe('WorkspaceManager', () => { created: true, payload, } as never)) - await expect(manager.create({ name: 'created' })).resolves.toMatchObject({ ok: true }) - expect(api.callsOf('workspace.create')).toEqual([{ name: 'created' }]) + await expect(manager.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(api.callsOf('workspace.create')).toEqual([{ path: '/w/created' }]) expect(manager.getSnapshot().items[0]?.workspaceId).toBe('created') api.onWorkspaceCreate = () => Promise.reject(new Error('create transport')) diff --git a/packages/client/runtime/tsconfig.json b/packages/client/runtime/tsconfig.json index b8949fc6db..f22a810522 100644 --- a/packages/client/runtime/tsconfig.json +++ b/packages/client/runtime/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 424015f1f6..059cf22ebc 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -1,5 +1,6 @@ /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ import type { Context } from 'cordis' +import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { @@ -88,6 +89,15 @@ export class FixtureSession implements SessionFace { throw new Error(`test session "${this.sessionId}": prompt is not stubbed — supply it on the fixture's session face`) } + /** + * Fail-loud stub; supply `readAttachment` on the fixture's session face to exercise it. + * @param _attachmentId - opaque durable attachment id. + * @returns never — always throws. + */ + readAttachment(_attachmentId: AttachmentIdType): never { + throw new Error(`test session "${this.sessionId}": readAttachment is not stubbed — supply it on the fixture's session face`) + } + /** * Fail-loud stub; supply `updateQueue` on the fixture's session face to exercise it. * @returns never — always throws. diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..9e1061ec8c 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -73,18 +73,17 @@ export class TestWorkspaces implements IWorkspaces { /** * Create a Workspace (recorded). The default echoes a view derived from * the input; stub for failure or list-coupled flows. - * @param input - exactly one Host create spelling. + * @param input - the Host create payload. * @returns the created Workspace view. */ - async create(input: { name: string } | { path: string }): Promise { + async create(input: { path: string }): Promise { this.calls.push({ method: 'create', args: [input] }) const stub = this.stubs.get('create') if (stub !== undefined) return await (stub(input) as Promise) - const title = 'name' in input ? input.name : input.path return { - workspaceId: `ws-${title}` as WorkspaceId, - title, - path: 'path' in input ? input.path : `/${input.name}`, + workspaceId: `ws-${input.path}` as WorkspaceId, + title: input.path, + path: input.path, sessionIds: [], } as unknown as WorkspaceView } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 0aeede27ac..23320387c5 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -520,6 +520,7 @@ describe('fixture session face', () => { await runtime.sessions.add({ id: 's1' }) const bare = runtime.sessions.behavior('s1') expect(() => bare.prompt()).toThrow(/prompt is not stubbed/) + expect(() => bare.readAttachment('att-1' as Parameters[0])).toThrow(/readAttachment is not stubbed/) expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/) expect(() => bare.cancel()).toThrow(/cancel is not stubbed/) expect(() => bare.command()).toThrow(/command is not stubbed/) @@ -567,8 +568,8 @@ describe('workspaces action face', () => { it('records every IWorkspaces verb with inert defaults and honors stubs', async () => { const runtime = await SlotTestRuntime.create() const ws = runtime.workspaces - const created = await ws.create({ name: 'alpha' }) - expect(created.title).toBe('alpha') + const created = await ws.create({ path: '/tmp/alpha' }) + expect(created.title).toBe('/tmp/alpha') const registered = await ws.create({ path: '/tmp/beta' }) expect(registered.path).toBe('/tmp/beta') await expect(ws.pickDirectory()).resolves.toBeNull() @@ -592,7 +593,7 @@ describe('workspaces action face', () => { ws.stub('openPath', () => Promise.resolve()) ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never)) ws.stub('archiveSession', () => Promise.resolve()) - expect((await ws.create({ name: 'y' })).title).toBe('X') + expect((await ws.create({ path: '/y' })).title).toBe('X') await expect(ws.pickDirectory()).resolves.toBe('/picked') expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S') await ws.delete('w1' as WorkspaceId) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 247afbee25..3170e8eada 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: f684f99c9e80a02e3ccad57c9a4d7246df0b192b -README.zh.md: 61c746e35d3a221d34cf9e830a63ce5d8fa0dbfd +README.md: 23dbb1a5492afefe9064d86429f21b926f594a53 +README.zh.md: 1d27beed9ca426096692b5710cc8d77396499449 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index f684f99c9e..23dbb1a549 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -18,7 +18,7 @@ Approvals take over the composer through the chain this package declares: `Appro The session header declares and renders the session-scoped `'conversation.session.header.actions'` list beside the title, allowing feature plugins to contribute controls without entering the skeleton. The composer chain currency includes the current conversation `session`; ui-subagent selects one-shot or parent-unavailable addressed sessions for reason-specific read-only copy, while the ordinary InputBar keeps every addressed child Send-only because the continuation service exposes no public per-Activation cancellation operation and `session.cancel` would bypass its ownership. -Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble carries an `插话` / `Interjection` caption above it, the only thing distinguishing a mid-turn interjection from the turn-opening prompt that shares its bubble. +Logged non-user messages render as a default-collapsed disclosure whose header names the role the runtime projected for the message — `上下文注入` for an injection, `跨会话召回` for a recalled session — followed by the producer name that projection read out of the durable source, so a reader distinguishes a skill catalog from a workspace instruction file or a recalled session without expanding. A source that names no producer shows the role alone. The shared `DisclosureRow` primitive gives this context surface the same compact geometry as other flow rows while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap and synthesizes no tool state or summary ([historical disclosure decision](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md), [producer-label decision](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md)). That body follows the form the producer declared on its durable source: `instructions` names the reconciled files above their text, `catalog` lists the entries the source recorded instead of the model-facing prose, and every other value — absent, unknown to this version, or carrying no usable fields — renders the opaque body, which shows the model-facing text with its real line breaks and the remaining source fields. The opaque body is the documented default, not a leftover: a resumed, forked, or foreign log must render whether or not its producer is mounted here. A durable or pending steering bubble shares the user bubble's presentation unadorned; its mid-turn position in the flow is the only steering signal the transcript shows. A Think row stays collapsed by default and exposes live reasoning throughput without expanding the chain of thought: while its reasoning block is the streaming tail, the summary switches from the settled first line to the latest non-blank line and its one-line scrollport follows each delta to the inline end. Expanding the row removes the moving summary and leaves the full reasoning in ordinary page flow, so page reading never fights an internal follower; settlement restores the stable first-line summary at the left edge ([decision](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 61c746e35d..1d27beed9c 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ Chat 业务行是彼此独立的注册表贡献,不是封闭的内建联合。 会话页头会在标题旁声明并渲染 Session scope 的 `'conversation.session.header.actions'` 列表,使功能插件无需进入骨架即可贡献控件。编辑器链的 currency 包含当前对话 `session`;ui-subagent 会选取 one-shot 或 parent 不可用的已寻址会话,并按原因显示只读文案,而普通 InputBar 会让所有已寻址 child 仅保留 Send,因为继续执行服务不公开逐 Activation 取消操作,`session.cancel` 也会绕过其所有权。 -已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡上方带有 `插话` / `Interjection` 标注,这是把中途插话与共用同一气泡的开轮提示区分开的唯一标识。 +已记录的非用户消息渲染为默认折叠的展开项,标题栏先给出运行时为该消息投影出的角色——注入为 `上下文注入`,召回为 `跨会话召回`——其后是该投影从持久来源读出的生产者名称,因此读者无需展开即可区分 skill(技能)目录、工作区指令文件与被召回的会话。来源未提供生产者名称时只显示角色。共享的 `DisclosureRow` 原子组件让该上下文界面与消息流中的其他紧凑行保持相同几何,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,且不会合成工具状态或摘要([历史展开项决策](../../../.agents/notes/archived/feature/2026-07-30-web-context-injection-disclosure.md)、[生产者标签决策](../../../.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md))。该内容区按生产方在持久来源上声明的形态渲染:`instructions` 在正文之上列出它对账过的文件,`catalog` 列出来源记录的条目而非面向模型的散文,其余取值——未声明、本版本不认识、或字段不可用——一律渲染 opaque 内容区,即按真实换行展示面向模型的文本,并把剩余来源字段列出。opaque 不是兜底剩余物而是有文档的默认:恢复的、fork 的、外部写入的日志,无论其生产方是否挂载在此处,都必须渲染得出来。持久或待处理的 steering(中途引导)气泡沿用用户气泡的呈现,不加任何装饰;transcript 中唯一的 steering 信号是它出现在轮次中途的位置。 Think 行默认保持折叠,并在不展开思维链的情况下暴露实时推理(reasoning)吞吐:当推理块是流式输出尾部时,摘要从结算后的首行切换到最新的非空行,其单行滚动区会随每个 delta 追到行内末端。展开该行会移除移动摘要,让完整推理进入普通页面流,因此页面阅读不会与内部跟随器争夺滚动;结算后恢复左对齐的稳定首行摘要([决策](../../../.agents/notes/implemented/feature/2026-08-02-web-thinking-tail-scroll.md))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index e78567cbf7..ea4d48b42b 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -40,6 +40,8 @@ }, "peerDependencies": { "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-attachment": "^0.0.1", + "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-client-locale": "^0.0.1", "@deepseek-ai/dsh-client-runtime": "^0.0.1", "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", @@ -56,6 +58,8 @@ }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index e5844c4761..7b0c9e8ad2 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -13,7 +13,7 @@ import type { } from './contract/slots.ts' import type { InputNotice } from './input/contract.ts' import { createChatStore } from './stores.ts' -import { ConversationService } from './service.ts' +import { ConversationService, UnsupportedImageMediaTypeError } from './service.ts' import type { IConversation } from './service.ts' import { ComposerBlockRegistry } from './input/blocks.ts' import type { ComposerBlock } from './input/blocks.ts' @@ -91,6 +91,13 @@ function scopedConversation(sessions: ISessions, id: SessionId): IConversation { return conversation } +/** Resolve package-internal attachment operations from the public service registration. */ +function concreteConversation(ctx: Context): ConversationService { + const conversation = ctx.get('conversation') as ConversationService | undefined + if (conversation === undefined) throw new Error('ui-conversation: conversation service unavailable') + return conversation +} + /** Chain routing: claim the composer while an approval wait is pending (pure — owner props only). */ function selectApproval({ interactions }: ComposerChainProps): ApprovalWait | null { return interactions.find((i): i is ApprovalWait => i.kind === 'approval') ?? null @@ -201,9 +208,16 @@ export function apply(ctx: Context): void { if (sessionId !== undefined && nextId !== sessionId) { const from = inputHub.shell(sessionId) const draft = from.snapshot.draft - if (draft !== '') { - inputHub.shell(nextId).setDraft(draft) - from.setDraft('') + const imageIds = from.snapshot.imageIds + const next = inputHub.shell(nextId) + if (imageIds.length === 0 || next.addImages(imageIds)) { + if (draft !== '') { + next.setDraft(draft) + from.setDraft('') + } + if (imageIds.length > 0) { + for (const id of imageIds) from.removeImage(id) + } } } sessions.open(nextId) @@ -220,10 +234,14 @@ export function apply(ctx: Context): void { 'conversation.view': { kind: 'list', scope: 'session' }, }, store: chatStore, - inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => ({ - views, - bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), - }), + inject: (sessionId: SessionId, _actions: BoundActions): ConversationSessionInjected => { + const conversation = concreteConversation(ctx) + return { + views, + releaseSessionImages: (id) => { conversation.releaseSessionImages(id) }, + bindDraftMirror: write => inputHub.shell(sessionId).bindMirror(write), + } + }, }, ConversationSession) // Header chrome sits above the resident scrollport but shares the same @@ -262,6 +280,9 @@ export function apply(ctx: Context): void { if (sessionId === undefined) { return { keyboard: undefined, + addImages: undefined, + removeImage: undefined, + draftImages: undefined, resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: undefined, @@ -270,10 +291,32 @@ export function apply(ctx: Context): void { hooks: { notices: ABSENT_NOTICES, lexicon: ABSENT_LEXICON, menuLauncher: ABSENT_MENU_LAUNCHER }, } } + const conversation = concreteConversation(ctx) const shell = inputHub.shell(sessionId) const slash = inputHub.slash(sessionId) return { keyboard: shell, + addImages: (files) => { + try { + const images = conversation.createDraftImages(files) + if (!shell.addImages(images.map(image => image.id))) { + conversation.releaseDraftImages(images) + } + return null + } catch (error: unknown) { + if (error instanceof UnsupportedImageMediaTypeError) { + return t('image.unsupportedType', { + type: error.mediaType || t('image.unknownType'), + }) + } + return error instanceof Error ? error.message : String(error) + } + }, + removeImage: (id) => { + conversation.releaseDraftImage(id) + shell.removeImage(id) + }, + draftImages: ids => conversation.draftImages(ids), resolveSubmitMode: (running, gesture, steeringAvailable) => submissionPolicy.resolve(running, gesture, steeringAvailable), toggleCommandMenu: slash === undefined @@ -332,6 +375,7 @@ export function apply(ctx: Context): void { }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions): ChatViewInjected => { + const conversation = concreteConversation(ctx) const scoped = scopedConversation(sessions, sessionId) return { openDetails: (target) => { @@ -347,6 +391,7 @@ export function apply(ctx: Context): void { }) }, loadOlder: () => { void scoped.loadOlder() }, + loadImage: attachment => conversation.resolveImage(sessionId, attachment), // Unregistered 'trajectory' id is safe: the tab ring falls back to // the first view, and the untouched inspect target stays inert. inspectCall: (callId) => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 6dd9f09b4d..7a8590c2b8 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -14,6 +14,7 @@ import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives' import type { MarkdownFileMentions } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import { ReasoningRow } from './ReasoningRow.tsx' import css from './AssistantMarkdown.module.css' @@ -22,6 +23,8 @@ export interface AssistantMarkdownProps { streaming: boolean /** Frozen partial of an aborted turn: rendered with a stopped marker. */ interrupted?: boolean | undefined + /** Session-authorized durable image loader. */ + loadImage?: ImageLoader /** Resolved prose file mentions for this Assistant's closing turn. */ mentions?: MarkdownFileMentions | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -30,8 +33,9 @@ export interface AssistantMarkdownProps { /** Reasoning block as the Think variant summary row (figma 39:28304). */ export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, mentions, t, + blocks, streaming, interrupted, loadImage, mentions, t, }: AssistantMarkdownProps) { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) // 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]) @@ -58,6 +62,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ /> ) case 'reasoning': return + case 'image': return // Grouped into tool rows by ChatView; hasVisible above skips an empty shell. case 'tool-call': return null default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx index d0f8b33e6f..72e8a6ae28 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantNodeView.tsx @@ -4,7 +4,7 @@ import { AssistantMarkdown } from './AssistantMarkdown.tsx' /** Streaming, settled, and interrupted Assistant states share one keyed renderer instance. */ export const AssistantNodeView = memo(function AssistantNodeView({ - node, useTurnData, openFile, fileMentions, t, + node, useTurnData, openFile, loadImage, fileMentions, t, }: ChatNodeViewProps<'assistant-step'>) { const data = node.data const turn = node.location.kind === 'turn' || node.location.kind === 'step' @@ -25,6 +25,7 @@ export const AssistantNodeView = memo(function AssistantNodeView({ blocks={data.blocks} streaming={data.status === 'running'} interrupted={data.status === 'interrupted'} + loadImage={loadImage} mentions={mentions} t={t} /> diff --git a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx index 1f7a2f7451..f3343a183f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatNodeSeat.tsx @@ -18,7 +18,7 @@ type RoutedChatNodeOwner = { /** Subscribe and dispatch one stable Context key without observing sibling Nodes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ nodeKey, selectedCallId, cwd, openFile, inspectCall, forkAt, - fileMentions, useSession, renderSlot, t, + loadImage, fileMentions, useSession, renderSlot, t, }: ChatNodeSeatProps) { const node = useSession(snapshot => snapshot.chat.nodes.get(nodeKey)) const routedNode = node as ChatNode | undefined @@ -30,8 +30,9 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ openFile, inspectCall, forkAt, + loadImage, fileMentions, - }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, fileMentions]) + }, [node, selectedCallId, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions]) if (routedNode === undefined || owner === null) return null // Runtime dispatch owns the correlation: every Node's discriminant is the // keyed-slot entry passed alongside that same Node. TypeScript does not diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index cae17b3865..3039ae762f 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -144,7 +144,7 @@ function TurnStatus({ startTime, t }: { * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, + useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, loadImage, inspectCall, chatScroll, forkAt, fileMentions, t, }: ChatViewSlotProps) { const order = useSession(s => s.chat.order) @@ -389,6 +389,7 @@ export function ChatView({ openFile={openFile} inspectCall={inspectCall} forkAt={forkAt} + loadImage={loadImage} fileMentions={fileMentions} renderSlot={renderSlot} t={t} @@ -401,7 +402,7 @@ export function ChatView({ wait, tool execution, streaming) so it never flickers per step. */} {running && } {pendingSteering.map(item => ( - + ))} {!atBottom && ( diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.module.css b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css new file mode 100644 index 0000000000..e05a6fc625 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.module.css @@ -0,0 +1,53 @@ +.gallery { + display: flex; + flex-wrap: wrap; + gap: 8px; + width: min(240px, 100%); +} + +.gallery[data-align='end'] { + justify-content: flex-end; + align-self: flex-end; +} + +.gallery[data-align='start'] { + justify-content: flex-start; + align-self: flex-start; +} + +.frame { + display: grid; + flex: 0 0 auto; + place-items: center; + min-width: 44px; + min-height: 44px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.frame img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.loading, +.error { + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; +} + +.error { + max-width: 240px; + padding: 10px 12px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 10px; + background: var(--dsw-alias-interactive-bg-hover-danger); + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageImage.tsx b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx new file mode 100644 index 0000000000..3f22ff72b8 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/MessageImage.tsx @@ -0,0 +1,72 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { ImageLightbox } from '../skeleton/ImageLightbox.tsx' +import css from './MessageImage.module.css' + +/** Loads a session-authorized durable image URL. */ +export type ImageLoader = (attachment: ImageAttachmentRef) => Promise + +/** Compact history renderer with retryable loading and double-click original preview. */ +export function MessageImage({ attachment, load, t }: { + attachment: ImageAttachmentRef + load: ImageLoader + t: ChatViewSlotProps['t'] +}) { + const [src, setSrc] = useState(null) + const [error, setError] = useState(false) + const [open, setOpen] = useState(false) + const close = useCallback(() => { setOpen(false) }, []) + const size = useMemo(() => { + const scale = Math.min(1, 240 / attachment.width, 240 / attachment.height) + return { width: Math.max(1, Math.round(attachment.width * scale)), height: Math.max(1, Math.round(attachment.height * scale)) } + }, [attachment.height, attachment.width]) + + const request = useCallback(() => { + setError(false) + setSrc(null) + void load(attachment).then(setSrc).catch(() => { setError(true) }) + }, [attachment, load]) + + useEffect(() => { + let live = true + setError(false) + void load(attachment).then((url) => { if (live) setSrc(url) }).catch(() => { if (live) setError(true) }) + return () => { live = false } + }, [attachment, load]) + + const label = attachment.name ?? t('image.label') + if (error) return + return ( + <> + + {open && src !== null && } + + ) +} + +/** Wrapping image group shared by user and assistant history. */ +export function ImageGallery({ images, load, align, t }: { + images: readonly { attachment: ImageAttachmentRef }[] + load: ImageLoader + align: 'start' | 'end' + t: ChatViewSlotProps['t'] +}) { + if (images.length === 0) return null + return ( +

+ {images.map((image, index) => ( + + ))} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css index c6ca35bb2c..cc1ded9822 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.module.css +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.module.css @@ -8,18 +8,17 @@ gap: 6px; } -/* Steering caption above the bubble: mid-turn interjections carry the same - bubble as a turn-opening prompt, so the transcript names which one this is. */ -.steeringMark { - padding-right: 4px; - color: var(--dsw-alias-label-tertiary); - font-size: 12px; - line-height: 16px; +.userStack { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 8px; + min-width: 0; + max-width: min(525px, 82%); } - .bubble { /* 525px cap inside the 736 column; percentage keeps narrow windows sane. */ - max-width: min(525px, 82%); + max-width: 100%; background: var(--dsw-specific-bubble); border-radius: 22px; /* 44px single-line bubble: 24 line + 10 vertical padding each side. */ diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a0d85e85e8..f7c451ce8a 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -1,30 +1,40 @@ // MessageItem: simple chat nodes — user and consumed-steering bubbles -// (right-aligned, with clock + copy IconActions; steering adds the -// interjection caption that names it; branch lives only under assistant -// answers), pending steering (caption + copy only), context injection, +// (right-aligned, with clock + copy IconActions; branch lives only under +// assistant answers), pending steering (copy only), context injection, // compaction marker, retry disclosure, and unknown-surface JSON rows. import { memo, useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' import type { - ModelRetryNode, TurnErrorNode, + ModelRetryNode, TurnErrorNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts' import { CompactionItem } from './CompactionItem.tsx' import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' +import { ImageGallery, type ImageLoader } from './MessageImage.tsx' import css from './MessageItem.module.css' -function contentText(content: readonly unknown[]): { text: string; rest: unknown[] } { +type UserImage = Extract + +function contentParts(content: readonly unknown[]): { + text: string + images: { attachment: UserImage['attachment'] }[] + rest: unknown[] +} { const texts: string[] = [] + const images: { attachment: UserImage['attachment'] }[] = [] const rest: unknown[] = [] for (const block of content) { - const b = block as { type?: string; text?: string } + const b = block as { type?: string; text?: string; attachment?: unknown } if (b.type === 'text' && typeof b.text === 'string') texts.push(b.text) + else if (b.type === 'image' && b.attachment !== undefined) { + images.push({ attachment: (b as UserImage).attachment }) + } else rest.push(block) } - return { text: texts.join(''), rest } + return { text: texts.join(''), images, rest } } function retrySeconds(milliseconds: number): number { @@ -151,25 +161,27 @@ function projectUserText(text: string): ReactNode { /** Right-aligned bubble shared by user and steering rows. */ function UserStyleBubble({ - content, actions, pending = false, steering = false, t, + content, imageLoader, actions, pending = false, t, }: { content: readonly unknown[] + imageLoader: ImageLoader /** Optional IconActions (or similar) below the bubble; receives the joined text. */ actions?: (text: string) => ReactNode /** Whether this is the Host-authoritative pre-admission steering projection. */ pending?: boolean - /** Marks the bubble as mid-turn steering rather than a turn-opening prompt. */ - steering?: boolean t: ChatViewSlotProps['t'] }): ReactNode { - const { text, rest } = contentText(content) + const { text, images, rest } = contentParts(content) const truncated = (total: number): string => t('json.truncated', { total }) + const showBubble = text !== '' || rest.length > 0 return (
- {steering && {t('message.steering')}} -
- {projectUserText(text)} - {rest.map((block, i) => )} +
+ + {showBubble &&
+ {projectUserText(text)} + {rest.map((block, i) => )} +
}
{actions?.(text)}
@@ -182,15 +194,17 @@ function UserStyleBubble({ * @param props - Pending message content and conversation translator. * @returns the pending steering bubble. */ -export function PendingSteeringBubble({ content, t }: { +export function PendingSteeringBubble({ content, loadImage, t }: { content: readonly unknown[] + loadImage?: ImageLoader t: ChatViewSlotProps['t'] }): ReactNode { + const imageLoader = loadImage ?? (() => Promise.reject(new Error(t('image.serviceUnavailable')))) return ( ( ) { const data = node.data return ( ( void inspectCall: (callId: CallId) => void forkAt: (seq: number) => void + /** Resolve a session-authorized historical image for inline display. */ + loadImage: (attachment: ImageAttachmentRef) => Promise fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } @@ -327,6 +340,8 @@ export interface ConversationSessionInjected { subscribe: (fn: () => void) => () => void version: () => number } + /** Release historical image URLs when this rendered session scope unmounts. */ + releaseSessionImages: (sessionId: SessionId) => void /** Bind the input machine's draft persistence mirror to the session store. */ bindDraftMirror: (write: (text: string) => void) => () => void } @@ -383,6 +398,12 @@ export interface ComposerBarOwnerProps { export interface ComposerBarInjected { /** The InputBar-exclusive keyboard/DOM command face (private plane); absent with the session. */ keyboard: ComposerKeyboard | undefined + /** Create previews and append image ids to the session input. */ + addImages: ((files: readonly File[]) => string | null) | undefined + /** Release one preview and remove its id from session input. */ + removeImage: ((id: DraftAttachmentId) => void) | undefined + /** Resolve ordered input ids to browser-owned draft images. */ + draftImages: ((ids: readonly DraftAttachmentId[]) => readonly ComposerAttachment[]) | undefined /** Resolve one keyboard submission gesture against the current running state and persisted preference. */ resolveSubmitMode: ( running: boolean, @@ -565,6 +586,8 @@ export interface ChatViewInjected { */ openFile: (path: string) => void loadOlder: () => void + /** Resolve a session-authorized historical image for inline display. */ + loadImage: (attachment: ImageAttachmentRef) => Promise /** Hand a call off to the trajectory view: write the one-shot inspect target and switch tabs. */ inspectCall: (callId: CallId) => void /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index e0471623f0..afd2f3ba19 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -16,6 +16,7 @@ export type {} from './conversation-nodes/turn-tail.ts' export { apply, inject } from './apply.ts' export { ConversationService } from './service.ts' export type { IConversation } from './service.ts' +export type { DraftAttachmentId } from './input/contract.ts' export type { CallId, ChatStoreState, SelectionTarget, ViewTab, @@ -28,7 +29,7 @@ export type { export type { ChatFileMentions, ChatNodeOwnerProps, ChatNodeViewProps, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, ComposerBarInjected, - ComposerChainProps, ConversationInjected, + ComposerAttachment, ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, DetailsToolOwnerProps, EmptyWorkspaceOwnerProps, TurnTailOwnerProps, UseChatNodeTurnData, diff --git a/packages/client/ui-conversation/src/client/input/contract.ts b/packages/client/ui-conversation/src/client/input/contract.ts index 9524da7f48..c6c6e77ba2 100644 --- a/packages/client/ui-conversation/src/client/input/contract.ts +++ b/packages/client/ui-conversation/src/client/input/contract.ts @@ -6,6 +6,7 @@ * (machine.ts) is package-private and never exported. */ import type { ClientContext, SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import type { Branded } from '@deepseek-ai/dsh-brand' import type { ArbitrateKey, ArbitrateOutcome, CommandClaim, ConsumeTokenRequest, PickOutcome, ReferenceInsert, SubmitOutcome, TokenSpan, @@ -13,6 +14,9 @@ import type { import type { QueueRow } from '../contract/queue.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' +/** Browser-runtime identity of one unsent image draft. */ +export type DraftAttachmentId = Branded<'DraftAttachmentId'> + /** * The scoped-event application verbs: the hub's bail listeners call these, * and the boolean answer IS the event's bail value (true ⟺ the machine @@ -29,6 +33,12 @@ export interface InputTarget { export interface SessionInput extends InputTarget { /** Single write path for draft text (all mutation rides machine events). */ setDraft(text: string): void + /** Append ordered browser-owned image ids; busy admission phases refuse. */ + addImages(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned image id. */ + removeImage(id: DraftAttachmentId): void + /** Drop ids whose browser-owned objects no longer exist. */ + pruneImages(ids: readonly DraftAttachmentId[]): void /** * THE complexity sink: enter adjudication, submit transaction, and the default sink live inside. * @param mode - delivery intent retained through asynchronous adjudication and serialization. @@ -63,6 +73,12 @@ export interface InputService { export interface InputActions { /** Single public draft write path (full next draft; occurrence math via diff scan). */ setDraft(text: string): void + /** Append ordered browser-owned image ids; busy admission phases refuse. */ + addImages(ids: readonly DraftAttachmentId[]): boolean + /** Remove one browser-owned image id. */ + removeImage(id: DraftAttachmentId): void + /** Drop ids whose browser-owned objects no longer exist. */ + pruneImages(ids: readonly DraftAttachmentId[]): void /** Enter submission (adjudication / claim transaction / default sink inside). */ submit(): void } @@ -192,6 +208,8 @@ export interface InputMachineOptions { /** Published input state (the currency; per-session). */ export interface InputState { readonly draft: string + /** Ordered runtime-only image ids; bytes and URLs stay in ConversationService. */ + readonly imageIds: readonly DraftAttachmentId[] /** Monotonic draft revision (span CAS compares against this). */ readonly draftRev: number readonly phase: 'plain' | 'adjudicating' | 'claimed' | 'submitting' diff --git a/packages/client/ui-conversation/src/client/input/facade.ts b/packages/client/ui-conversation/src/client/input/facade.ts index 96226e5b04..d540885608 100644 --- a/packages/client/ui-conversation/src/client/input/facade.ts +++ b/packages/client/ui-conversation/src/client/input/facade.ts @@ -13,7 +13,7 @@ import type { ReferenceInsert, SlashController, TokenSpan, } from '@deepseek-ai/dsh-client-ui-slash/client' import type { - EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, + DraftAttachmentId, EditRange, EditSelection, InputActions, InputEffect, InputNotice, InputState, PasteComponent, QueuedMessage, SessionInput, SubmitAttempt, } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' @@ -45,7 +45,7 @@ export interface SessionInputDeps { */ steerQueue?: (() => void) | undefined /** The plain-message sink (send choreography / materialize fork — the hub owns it). */ - defaultSink(text: string, mode: InputSubmitMode): void + defaultSink(text: string, imageIds: readonly DraftAttachmentId[], mode: InputSubmitMode): void } /** Guard tier from the machine phase. */ @@ -74,6 +74,9 @@ export class SessionInputShell implements SessionInput { /** The public provide-channel action face (one stable identity per session). */ readonly actions: InputActions = { setDraft: (text) => { this.setDraft(text) }, + addImages: ids => this.addImages(ids), + removeImage: (id) => { this.removeImage(id) }, + pruneImages: (ids) => { this.pruneImages(ids) }, submit: () => { this.submit('queue') }, } @@ -82,6 +85,7 @@ export class SessionInputShell implements SessionInput { private readonly core = new InputMachine({ now: () => Date.now() }) private noticeSeq = 0 private lastDraft = '' + private imageIds: readonly DraftAttachmentId[] = [] private disposed = false /** Draft persistence mirror (chat store write; receives the clipboard projection, never raw placeholders). */ private mirrorFn: ((text: string) => void) | undefined @@ -103,12 +107,54 @@ export class SessionInputShell implements SessionInput { this.run(this.core.dispatch({ type: 'draft-changed', draft: text, ...(editRange !== undefined ? { editRange } : {}) })) } + /** Append ordered image ids unless an admission transaction is locked. */ + addImages(ids: readonly DraftAttachmentId[]): boolean { + if (this.snapshot.phase === 'adjudicating' || this.snapshot.phase === 'submitting') return false + if (ids.length === 0) return true + this.imageIds = [...this.imageIds, ...ids] + this.publish() + return true + } + + /** Remove one image id from this draft. */ + removeImage(id: DraftAttachmentId): void { + const next = this.imageIds.filter(candidate => candidate !== id) + if (next.length === this.imageIds.length) return + this.imageIds = next + this.publish() + } + + /** + * Keep only image ids that still resolve in the browser attachment registry. + * @param available - live registry ids. + */ + pruneImages(available: readonly DraftAttachmentId[]): void { + const keep = new Set(available) + const next = this.imageIds.filter(id => keep.has(id)) + if (next.length === this.imageIds.length) return + this.imageIds = next + this.publish() + } + + /** + * Restore a failed attempt before any images added after its admission. + * @param ids - failed attempt image ids. + */ + restoreImages(ids: readonly DraftAttachmentId[]): void { + const current = new Set(this.imageIds) + this.imageIds = [...ids.filter(id => !current.has(id)), ...this.imageIds] + this.publish() + } + /** * Clear the draft as a successful-send commit: no undo unit is recorded and * the undo history is cut, so Ctrl/Cmd-Z cannot resurrect sent content * (the command path gets the same discipline from submit-settled success). + * @param imageIds - admitted image ids to remove from this draft. */ - commitSend(): void { + commitSend(imageIds: readonly DraftAttachmentId[]): void { + const submitted = new Set(imageIds) + this.imageIds = this.imageIds.filter(id => !submitted.has(id)) this.run(this.core.dispatch({ type: 'send-committed' })) } @@ -150,6 +196,10 @@ export class SessionInputShell implements SessionInput { * dismisses and the menu tracks frozen. */ submit(mode: InputSubmitMode = 'queue'): void { + if (this.snapshot.draft.trim() === '' && this.imageIds.length > 0) { + if (this.snapshot.phase === 'plain') this.deps.defaultSink('', [...this.imageIds], mode) + return + } this.run(this.core.dispatch({ type: 'enter', mode })) const phase = this.snapshot.phase if (phase === 'adjudicating' || phase === 'submitting') { @@ -364,9 +414,10 @@ export class SessionInputShell implements SessionInput { * the clipboard text. Chip-free drafts skip the async detour. */ private sinkSerialized(draft: string, mode: InputSubmitMode): void { + const imageIds = [...this.imageIds] const occurrences = this.core.state.occurrences if (occurrences.length === 0) { - this.deps.defaultSink(draft.trim(), mode) + this.deps.defaultSink(draft.trim(), imageIds, mode) return } const slash = this.deps.slash?.() @@ -386,7 +437,7 @@ export class SessionInputShell implements SessionInput { cursor = part.offset + 1 } out += draft.slice(cursor) - this.deps.defaultSink(out.trim(), mode) + this.deps.defaultSink(out.trim(), imageIds, mode) }, (error: unknown) => { controller.abort() @@ -444,7 +495,7 @@ export class SessionInputShell implements SessionInput { private compose(): InputState { const core = this.core.state - return { ...core, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE } + return { ...core, imageIds: this.imageIds, queue: this.deps.queue?.getSnapshot() ?? EMPTY_QUEUE } } private publish(): void { diff --git a/packages/client/ui-conversation/src/client/input/hub.ts b/packages/client/ui-conversation/src/client/input/hub.ts index 5c36dbf4c3..fffeb7ca7f 100644 --- a/packages/client/ui-conversation/src/client/input/hub.ts +++ b/packages/client/ui-conversation/src/client/input/hub.ts @@ -12,7 +12,7 @@ import type { ClientContext, ISessions, SessionBinding, SessionFace, SessionId } import type { SlashController } from '@deepseek-ai/dsh-client-ui-slash/client' import type { TranslateNS } from '@deepseek-ai/dsh-client-locale/client' import { queueReadFaceOf } from '../queue/store.ts' -import type { ComposerKeyboard, InputService, SessionInput } from './contract.ts' +import type { ComposerKeyboard, DraftAttachmentId, InputService, SessionInput } from './contract.ts' import type { InputSubmitMode } from '../contract/composer-submission.ts' import type { PopupDismissFace } from './facade.ts' import { SessionInputShell } from './facade.ts' @@ -22,6 +22,17 @@ interface CommandFace { popupFor(actx: ClientContext): PopupDismissFace } +/** Attachment-send face resolved lazily to keep hub/service construction acyclic. */ +interface ConversationAttachmentFace { + sendSession( + session: SessionFace, + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + ): Promise + releaseDraftImage(id: DraftAttachmentId): void +} + /** Session-addressed input facade registry (InputService face + composer-layer extras). */ export class InputHub implements InputService { private readonly shells = new Map() @@ -64,7 +75,7 @@ export class InputHub implements InputService { slash: () => this.controller(actx), popup: () => this.popup(actx), queue: queueReadFaceOf(session), - defaultSink: (text, mode) => { this.sink(session, text, mode) }, + defaultSink: (text, imageIds, mode) => { this.sink(session, text, imageIds, mode) }, steerQueue: () => { void this.steerQueue(session, shell) }, }) this.shells.set(id, shell) @@ -83,8 +94,11 @@ export class InputHub implements InputService { ] return () => { for (const off of offs) off() + const drafts = shell.snapshot.imageIds shell.dispose() this.shells.delete(id) + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const imageId of drafts) conversation?.releaseDraftImage(imageId) } }, 'conversation.input: session shell') return shell @@ -132,19 +146,25 @@ export class InputHub implements InputService { * exactly one path; a failed first prompt is an ordinary prompt failure * (error strip via promptError, draft restored only while untouched). */ - private sink(session: SessionFace, text: string, mode: InputSubmitMode): void { - if (text === '') return + private sink( + session: SessionFace, + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + ): void { + if (text === '' && imageIds.length === 0) return const shell = this.shells.get(session.sessionId) // Commit, not an editable clear: undo must not resurrect sent content. - shell?.commitSend() - void session.prompt([{ type: 'text', text }], mode).then( - (result) => { - if (!result.ok && shell?.snapshot.draft === '') shell.setDraft(text) - }, - () => { + shell?.commitSend(imageIds) + void this.conversation().sendSession(session, text, imageIds, mode).catch(() => { + if (this.shells.get(session.sessionId) === shell) { + shell?.restoreImages(imageIds) if (shell?.snapshot.draft === '') shell.setDraft(text) - }, - ) + return + } + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + for (const id of imageIds) conversation?.releaseDraftImage(id) + }) } /** @@ -186,4 +206,10 @@ export class InputHub implements InputService { if (sessions === undefined) throw new Error('conversation.input: sessions service unavailable') return sessions } + + private conversation(): ConversationAttachmentFace { + const conversation = this.rootCtx.get('conversation') as ConversationAttachmentFace | undefined + if (conversation === undefined) throw new Error('conversation.input: conversation service unavailable') + return conversation + } } diff --git a/packages/client/ui-conversation/src/client/input/machine.ts b/packages/client/ui-conversation/src/client/input/machine.ts index 68147adc38..f929cbc7a6 100644 --- a/packages/client/ui-conversation/src/client/input/machine.ts +++ b/packages/client/ui-conversation/src/client/input/machine.ts @@ -133,6 +133,7 @@ export class InputMachine { const c = this.claim return { draft: this.draft, + imageIds: [], draftRev: this.draftRev, phase: this.phase, ...(c ? { claim: { token: c.token, ...(c.hint !== undefined ? { hint: c.hint } : {}) } } : {}), diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 7e3c2631e6..b022219bc7 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -25,6 +25,20 @@ export const zh = { 'input.send': '发送消息', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter 插话发送全部排队消息', 'input.accessMode': '访问模式,当前:{name}', + 'image.dropHint': '松开以添加图片', + 'image.pending': '待发送图片', + 'image.openOriginal': '双击查看原图', + 'image.openOriginalLabel': '{label},双击查看原图', + 'image.remove': '移除图片 {name}', + 'image.original': '原图', + 'image.label': '图片', + 'image.loadFailed': '图片加载失败,点击重试', + 'image.loading': '图片加载中…', + 'image.preview': '原图预览', + 'image.closePreview': '关闭原图预览', + 'image.serviceUnavailable': '图片读取服务不可用', + 'image.unsupportedType': '不支持的图片格式:{type}', + 'image.unknownType': '未知格式', 'context.aria': '上下文已用 {percent}', 'context.used': '上下文已用', 'context.system': '系统提示词', @@ -80,7 +94,6 @@ export const zh = { 'message.context.relay.from': '来自会话 {session}', 'message.context.recall.counts': '保留 {retained} 条 · 省略 {omitted} 条', 'message.context.recall.truncated': '已截断', - 'message.steering': '插话', 'message.compaction': '上下文已压缩', 'message.compaction.running': '正在压缩…', 'message.compaction.completed': '已压缩 {items} 条历史记录(约 {tokens} tokens)', @@ -169,6 +182,20 @@ export const en = { 'input.send': 'Send message', 'placeholder.steerQueue': 'Cmd/Ctrl+Enter steers all queued messages', 'input.accessMode': 'Access mode, current: {name}', + 'image.dropHint': 'Drop to add images', + 'image.pending': 'Pending images', + 'image.openOriginal': 'Double-click to view original', + 'image.openOriginalLabel': '{label}, double-click to view original', + 'image.remove': 'Remove image {name}', + 'image.original': 'Original image', + 'image.label': 'Image', + 'image.loadFailed': 'Image failed to load; click to retry', + 'image.loading': 'Loading image…', + 'image.preview': 'Original image preview', + 'image.closePreview': 'Close original image preview', + 'image.serviceUnavailable': 'Image loading service unavailable', + 'image.unsupportedType': 'Unsupported image format: {type}', + 'image.unknownType': 'unknown format', 'context.aria': '{percent} of context used', 'context.used': 'of context used', 'context.system': 'System prompt', @@ -224,7 +251,6 @@ export const en = { 'message.context.relay.from': 'From session {session}', 'message.context.recall.counts': '{retained} kept · {omitted} omitted', 'message.context.recall.truncated': 'truncated', - 'message.steering': 'Interjection', 'message.compaction': 'Context compacted', 'message.compaction.running': 'Compacting context…', 'message.compaction.completed': 'Compacted {items} history items (~{tokens} tokens)', diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index f9190da3e4..f8d7095491 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -13,9 +13,12 @@ import type { Context } from 'cordis' // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. import type { ISessions, SessionFace, SessionId } from '@deepseek-ai/dsh-client-runtime/client' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import type { ComposerAttachment } from './contract/slots.ts' import type { QueueAction, QueueItemId } from './contract/queue.ts' import type { ComposerBlocks } from './input/blocks.ts' -import type { InputService } from './input/contract.ts' +import type { DraftAttachmentId, InputService } from './input/contract.ts' +import type { InputSubmitMode } from './contract/composer-submission.ts' /** * The outward conversation face (`ctx.conversation`): the scope-addressed @@ -55,12 +58,46 @@ export interface IConversation { loadOlder(): Promise } +/** Create one browser-only draft descriptor; only its id enters input state. */ +function browserDraftAttachment(file: File): ComposerAttachment { + return { + kind: 'image', + id: crypto.randomUUID() as DraftAttachmentId, + previewUrl: URL.createObjectURL(file), + file, + } +} + +interface ImageUrlEntry { + readonly sessionId: SessionId + readonly generation: number + readonly pending: Promise +} + +/** Unsupported browser-declared image type, localized by the UI boundary. */ +export class UnsupportedImageMediaTypeError extends Error { + /** Browser-declared MIME value, possibly empty. */ + readonly mediaType: string + + /** @param mediaType - Browser-declared MIME value, possibly empty. */ + constructor(mediaType: string) { + super(`unsupported image media type: ${mediaType || '(empty)'}`) + this.name = 'UnsupportedImageMediaTypeError' + this.mediaType = mediaType + } +} + /** Scope-addressed conversation service (root singleton, provided as `conversation`). */ export class ConversationService extends Service implements IConversation { /** The per-session input machine registry (InputService face). */ readonly input: InputService /** The per-session composer-block registry. */ readonly blocks: ComposerBlocks + private readonly draftAttachments = new Map() + private readonly imageUrls = new Map() + private readonly imageGenerations = new Map() + private readonly createdImageUrls = new Set() + private disposed = false /** * @param ctx - owning root context (the plugin apply context; the service @@ -73,6 +110,14 @@ export class ConversationService extends Service implements IConversation { super(ctx, 'conversation') this.input = config.input this.blocks = config.blocks + ctx.effect(() => () => { + this.disposed = true + for (const url of this.createdImageUrls) revokePreview(url) + this.createdImageUrls.clear() + this.draftAttachments.clear() + this.imageUrls.clear() + this.imageGenerations.clear() + }, 'conversation attachment URL cache') } /** @@ -87,6 +132,136 @@ export class ConversationService extends Service implements IConversation { if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) } + /** + * Submit ordered draft images with text through one host admission. + * @param session - target session. + * @param text - serialized prompt text. + * @param imageIds - ordered draft-local attachment ids. + * @param mode - queue or steer delivery selected by composer policy. + */ + async sendSession( + session: SessionFace, + text: string, + imageIds: readonly DraftAttachmentId[], + mode: InputSubmitMode, + ): Promise { + const attachments = this.draftImages(imageIds) + if (attachments.length !== imageIds.length) { + throw new Error('conversation.sendSession: one or more draft images are no longer available') + } + const uploaded = await this.serializeImages(attachments.map(attachment => attachment.file)) + const content = [...uploaded, ...(text === '' ? [] : [{ type: 'text' as const, text }])] + const result = await session.prompt(content, mode) + if (!result.ok) throw new Error(`conversation.send failed: ${result.error.code}: ${result.error.message}`) + this.releaseDraftImages(attachments) + } + + /** + * Create runtime-only draft images and their object URLs. + * @param files - browser files to register after MIME validation. + * @returns ordered draft descriptors. + */ + createDraftImages(files: readonly File[]): readonly ComposerAttachment[] { + for (const file of files) imageMediaType(file.type) + return files.map((file) => { + const attachment = browserDraftAttachment(file) + this.draftAttachments.set(attachment.id, attachment) + this.createdImageUrls.add(attachment.previewUrl) + return attachment + }) + } + + /** + * Resolve ordered input-state ids to runtime-owned draft images. + * @param ids - draft attachment ids. + * @returns descriptors that remain live, in requested order. + */ + draftImages(ids: readonly DraftAttachmentId[]): readonly ComposerAttachment[] { + const attachments: ComposerAttachment[] = [] + for (const id of ids) { + const attachment = this.draftAttachments.get(id) + if (attachment !== undefined) attachments.push(attachment) + } + return attachments + } + + /** + * Release one browser-owned draft image and preview URL. + * @param id - draft attachment id. + */ + releaseDraftImage(id: DraftAttachmentId): void { + const attachment = this.draftAttachments.get(id) + if (attachment === undefined) return + this.draftAttachments.delete(id) + this.createdImageUrls.delete(attachment.previewUrl) + revokePreview(attachment.previewUrl) + } + + /** + * Release a set of browser-owned draft images. + * @param attachments - descriptors to release. + */ + releaseDraftImages(attachments: readonly ComposerAttachment[]): void { + for (const attachment of attachments) this.releaseDraftImage(attachment.id) + } + + /** + * Resolve and cache one session-authorized historical image URL. + * @param sessionId - owning session authorization scope. + * @param attachment - durable image reference. + * @returns browser URL valid until its rendered session is released. + */ + resolveImage(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + if (this.disposed) return Promise.reject(new Error('conversation.resolveImage: service is disposed')) + const key = `${sessionId}:${attachment.attachmentId}` + const cached = this.imageUrls.get(key) + if (cached !== undefined) return cached.pending + const generation = this.imageGenerations.get(sessionId) ?? 0 + const session = this.requireSessions().binding(sessionId)?.session + if (session === undefined) { + return Promise.reject(new Error(`conversation.resolveImage: unknown session "${sessionId}"`)) + } + const pending = session.readAttachment(attachment.attachmentId) + .then((result) => { + if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) + if (this.disposed) throw new Error('conversation.resolveImage: service was disposed before loading completed') + if ((this.imageGenerations.get(sessionId) ?? 0) !== generation) { + throw new Error('historical image scope was released before loading completed') + } + if (typeof URL.createObjectURL !== 'function') { + return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` + } + const bytes = Uint8Array.from(result.value.data) + const url = URL.createObjectURL(new Blob([bytes.buffer], { type: result.value.attachment.mediaType })) + this.createdImageUrls.add(url) + return url + }) + .catch((error: unknown) => { + if (this.imageUrls.get(key)?.generation === generation) this.imageUrls.delete(key) + throw error + }) + this.imageUrls.set(key, { sessionId, generation, pending }) + return pending + } + + /** + * Release every historical image URL owned by one rendered session. + * @param sessionId - rendered session scope. + */ + releaseSessionImages(sessionId: SessionId): void { + this.imageGenerations.set(sessionId, (this.imageGenerations.get(sessionId) ?? 0) + 1) + for (const [key, entry] of this.imageUrls) { + if (entry.sessionId !== sessionId) continue + this.imageUrls.delete(key) + void entry.pending.then((url) => { + if (!this.createdImageUrls.delete(url)) return + revokePreview(url) + }, () => { + // A failed or invalidated load owns no object URL. + }) + } + } + /** Apply one operation to a pending queue occurrence. */ async updateQueue(itemId: QueueItemId, action: QueueAction): Promise { const session = this.scopedSession('updateQueue') @@ -136,4 +311,39 @@ export class ConversationService extends Service implements IConversation { if (sessions === undefined) throw new Error('conversation: sessions service unavailable') return sessions } + + /** Convert browser files to canonical base64 prompt parts. */ + private serializeImages(images: readonly File[]): Promise[0]> { + return Promise.all(images.map(async file => ({ + type: 'image' as const, + mediaType: imageMediaType(file.type), + data: bytesToBase64(new Uint8Array(await file.arrayBuffer())), + ...(file.name === '' ? {} : { name: file.name }), + }))) + } +} + +function imageMediaType(value: string): ImageMediaType { + switch (value) { + case 'image/png': + case 'image/jpeg': + case 'image/webp': + case 'image/gif': + return value + default: + throw new UnsupportedImageMediaTypeError(value) + } +} + +function bytesToBase64(data: Uint8Array): string { + let binary = '' + const chunk = 0x8000 + for (let offset = 0; offset < data.length; offset += chunk) { + binary += String.fromCharCode(...data.subarray(offset, offset + chunk)) + } + return btoa(binary) +} + +function revokePreview(url: string): void { + if (url.startsWith('blob:')) URL.revokeObjectURL(url) } diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx index 35bdab481b..80e6ac1202 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx @@ -121,8 +121,8 @@ export function ConversationSessionHeader({ * @returns the active view area, or null while the Session remains blank. */ export function ConversationSession({ - useSession, useInput, inputActions, useStore, actions, - renderSlot, views, bindDraftMirror, + sessionId, useSession, useInput, inputActions, useStore, actions, + renderSlot, views, bindDraftMirror, releaseSessionImages, }: ConversationSessionProps) { useSyncExternalStore(views.subscribe, views.version) const tabs = views.list() @@ -143,6 +143,10 @@ export function ConversationSession({ // the machine mirror, not this seed effect. }, [inputActions]) + useEffect(() => () => { + releaseSessionImages(sessionId) + }, [releaseSessionImages, sessionId]) + if (blank && composerPhase === 'blank') return null return (
diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css new file mode 100644 index 0000000000..d6b96f9fd7 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.module.css @@ -0,0 +1,34 @@ +.backdrop { + position: fixed; + inset: 0; + z-index: 1000; + display: grid; + place-items: center; + padding: 40px; + background: color-mix(in srgb, var(--dsw-alias-label-primary) 74%, transparent); +} + +.image { + max-width: min(100%, 1600px); + max-height: calc(100vh - 80px); + object-fit: contain; + border-radius: 12px; + background: var(--dsw-specific-input-major); + box-shadow: var(--dsw-shadow-lv3); +} + +.close { + position: fixed; + top: 20px; + right: 20px; + display: grid; + place-items: center; + width: 36px; + height: 36px; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 999px; + background: var(--dsw-specific-input-major); + color: var(--dsw-alias-label-primary); + font-size: 24px; + cursor: pointer; +} diff --git a/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx new file mode 100644 index 0000000000..43cbc441a5 --- /dev/null +++ b/packages/client/ui-conversation/src/client/skeleton/ImageLightbox.tsx @@ -0,0 +1,40 @@ +import { useEffect, useRef } from 'react' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import css from './ImageLightbox.module.css' + +/** Document-level original-image preview opened by an explicit double-click. */ +export function ImageLightbox({ src, alt, onClose, t }: { + src: string + alt: string + onClose: () => void + t: ChatViewSlotProps['t'] +}) { + const closeRef = useRef(null) + const restoreRef = useRef(null) + + useEffect(() => { + restoreRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null + closeRef.current?.focus() + const onKeyDown = (event: globalThis.KeyboardEvent): void => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => { + window.removeEventListener('keydown', onKeyDown) + restoreRef.current?.focus() + } + }, [onClose]) + + return ( +
{ if (event.target === event.currentTarget) onClose() }} + > + {alt} + +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 26f3532426..4603013e65 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -102,6 +102,25 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +.dragActive { + border-color: var(--dsw-alias-state-business-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); +} + +.dropHint { + position: absolute; + z-index: 2; + inset: 4px; + display: grid; + place-items: center; + border-radius: 16px; + background: color-mix(in srgb, var(--dsw-specific-input-major) 88%, var(--dsw-alias-state-business-primary)); + color: var(--dsw-alias-state-business-primary); + font-size: 14px; + font-weight: 600; + pointer-events: none; +} + .accessory { display: flex; align-items: center; @@ -109,6 +128,57 @@ padding: 10px 12px 0; } +.attachments { + display: flex; + gap: 8px; + min-width: 0; + padding: 12px 12px 0; + overflow-x: auto; + overflow-y: hidden; +} + +.attachment { + position: relative; + flex: 0 0 72px; + width: 72px; + height: 72px; +} + +.thumbnail { + width: 72px; + height: 72px; + padding: 0; + overflow: hidden; + border: 1px solid var(--dsw-alias-border-l2-darkmode-thin); + border-radius: 12px; + background: var(--dsw-alias-interactive-bg-hover); + cursor: zoom-in; +} + +.thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.remove { + position: absolute; + top: -6px; + right: -6px; + display: grid; + place-items: center; + width: 22px; + height: 22px; + padding: 0; + border: 1px solid var(--dsw-specific-input-major); + border-radius: 999px; + background: var(--dsw-alias-label-primary); + color: var(--dsw-specific-input-major); + font-size: 16px; + line-height: 1; + cursor: pointer; +} + /* Floating overlay anchor (menu / popupSelect shell): entries position themselves against the card (bottom: 100% + gap); closed entries render null. */ .overlayAnchor { diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 9044f9dbeb..13edd00c37 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -6,8 +6,8 @@ * region-slot content) ride the owner props. Session facts * (running/removed/promptError) are self-selected via useSession. */ -import { useEffect, useRef } from 'react' -import type { ChangeEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ChangeEvent, DragEvent, KeyboardEvent, MouseEvent, ReactNode } from 'react' import clsx from 'clsx' import { IconPlusOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: the `plan` projection key merge (the TodoDock posture — the @@ -16,10 +16,11 @@ import type {} from '@deepseek-ai/dsh-plan-mode/client' // Type-only: the `goal` projection key merge (hint disambiguation). import type {} from '@deepseek-ai/dsh-goal/client' import type { Translate } from '@deepseek-ai/dsh-client-ui-slots' -import type { ComposerBarProps } from '../contract/slots.ts' +import type { ComposerAttachment, ComposerBarProps } from '../contract/slots.ts' import { deriveDecorations } from '../input/decorations.ts' import type { DraftDecorations } from '../input/decorations.ts' import { ContextMeter } from './ContextMeter.tsx' +import { ImageLightbox } from './ImageLightbox.tsx' import { PermissionSelect } from './PermissionSelect.tsx' import css from './InputBar.module.css' @@ -35,7 +36,8 @@ export interface InputBarError { export type InputBarProps = ComposerBarProps export function InputBar({ - useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, + useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, + resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder, accessory, overlay, leftItems, rightItems, footer, @@ -63,8 +65,16 @@ export function InputBar({ // current; the bar renders the same DOM inert instead of a parallel tree. const live = input !== undefined && keyboard !== undefined && inputActions !== undefined const draft = input?.draft ?? '' - const empty = draft.trim() === '' + const attachments = useMemo( + () => input === undefined || draftImages === undefined ? [] : draftImages(input.imageIds), + [draftImages, input?.imageIds], + ) + const empty = draft.trim() === '' && attachments.length === 0 + const [preview, setPreview] = useState(null) + const [dragActive, setDragActive] = useState(false) + const [dropError, setDropError] = useState(null) const inputRef = useRef(null) + const dragDepthRef = useRef(0) const scrollRef = useRef(null) const mirrorRef = useRef(null) // IME guard: composition Enter picks a candidate, it must not send. The ref outlives renders; @@ -102,6 +112,17 @@ export function InputBar({ const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') + useEffect(() => { + if (input === undefined || inputActions === undefined) return + if (attachments.length !== input.imageIds.length) { + inputActions.pruneImages(attachments.map(attachment => attachment.id)) + } + }, [attachments, input?.imageIds, inputActions]) + + useEffect(() => { + if (preview !== null && !attachments.some(attachment => attachment.id === preview.id)) setPreview(null) + }, [attachments, preview]) + // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does // not act. @@ -330,8 +351,16 @@ export function InputBar({ const onPaste = (e: React.ClipboardEvent): void => { if (keyboard === undefined) return // absent machine: disabled textarea, no events if (machineBusy || locked) return + const files = Array.from(e.clipboardData.items) + .filter(item => item.kind === 'file') + .map(item => item.getAsFile()) + .filter((file): file is File => file !== null) + if (files.length > 0 && addImages !== undefined) setDropError(addImages(files)) const text = e.clipboardData.getData('text/plain') - if (text === '') return + if (text === '') { + if (files.length > 0) e.preventDefault() + return + } e.preventDefault() const el = e.currentTarget const sel = selectionOf(el) @@ -345,6 +374,39 @@ export function InputBar({ keyboard.track(keyboard.snapshot.draft, caret) } + const onDragEnter = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + if (locked || machineBusy || addImages === undefined) return + dragDepthRef.current += 1 + setDropError(null) + setDragActive(true) + } + + const onDragOver = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + event.dataTransfer.dropEffect = locked || machineBusy || addImages === undefined ? 'none' : 'copy' + } + + const onDragLeave = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files') || locked || machineBusy) return + dragDepthRef.current = Math.max(0, dragDepthRef.current - 1) + if (dragDepthRef.current === 0) setDragActive(false) + } + + const onDrop = (event: DragEvent): void => { + if (!event.dataTransfer.types.includes('Files')) return + event.preventDefault() + dragDepthRef.current = 0 + setDragActive(false) + if (locked || machineBusy || addImages === undefined) return + const dropped = [...event.dataTransfer.files] + if (dropped.length > 0) setDropError(addImages(dropped)) + } + + const closePreview = useCallback(() => { setPreview(null) }, []) + const onSelect = (e: React.SyntheticEvent): void => { // Any caret/selection gesture ends a live paste attempt (the machine // cannot observe DOM selection). Cheap no-op when none is live. @@ -477,9 +539,43 @@ export function InputBar({ {notice.text}
)} -
+ {dropError !== null &&
{dropError}
} +
+ {dragActive &&
{t('image.dropHint')}
} {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} + {attachments.length > 0 && ( +
+ {attachments.map(attachment => ( +
+ + +
+ ))} +
+ )} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the stack to the draft's FULL height (counting rows by '\n' cannot see soft wraps); the absolutely-positioned backdrop and textarea ride that height, and .scroll — capped at 14 @@ -508,7 +604,10 @@ export function InputBar({ ? t('placeholder.steerQueue') : planActive ? t('placeholder.plan') : t('placeholder.default'))} rows={2} - onChange={onChange} + onChange={(event) => { + setDropError(null) + onChange(event) + }} onKeyDown={onKeyDown} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} @@ -585,6 +684,14 @@ export function InputBar({
+ {preview !== null && ( + + )} {footer} ) diff --git a/packages/client/ui-conversation/src/client/stores.ts b/packages/client/ui-conversation/src/client/stores.ts index 8a1cd6f068..5a4913f0fa 100644 --- a/packages/client/ui-conversation/src/client/stores.ts +++ b/packages/client/ui-conversation/src/client/stores.ts @@ -9,8 +9,6 @@ import type { CallId, ChatStoreState, SelectionTarget } from './contract/views.t type ChatActions = { select: (draft: ChatStoreState, target: SelectionTarget | null) => void setDraft: (draft: ChatStoreState, text: string) => void - clearDraft: (draft: ChatStoreState) => void - restoreDraft: (draft: ChatStoreState, text: string) => void setView: (draft: ChatStoreState, view: string) => void setInspect: (draft: ChatStoreState, target: { callId: CallId } | null) => void } @@ -21,15 +19,14 @@ type ChatActions = { */ export function createChatStore(): EngineStoreHandle { return defineStore({ + // Anchored to the contract shape: consumers read the store through + // PropsStore's SnapshotSelectorHook, so init + // and the contract cannot drift. init: (): ChatStoreState => ({ selection: null, draft: '', view: null, inspect: null }), persist: 'dsh.conversation.chat', actions: { select: (d, target: SelectionTarget | null) => { d.selection = target }, setDraft: (d, text: string) => { d.draft = text }, - clearDraft: (d) => { d.draft = '' }, - // Optimistic-send failure restore: only when the user typed nothing new - // since the clear (send choreography lives in the inject factory). - restoreDraft: (d, text: string) => { if (d.draft === '') d.draft = text }, setView: (d, view: string) => { d.view = view }, setInspect: (d, target: { callId: CallId } | null) => { d.inspect = target }, }, diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index 6037405336..5d1f027923 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -229,7 +229,7 @@ describe('MessageItem arms', () => { expect(vi.getTimerCount()).toBe(0) }) - it('consumed steering is captioned as an interjection and keeps copy without branch', () => { + it('consumed steering renders as a plain user bubble and keeps copy without branch', () => { const writeText = vi.fn().mockResolvedValue(undefined) Object.defineProperty(navigator, 'clipboard', { configurable: true, @@ -242,7 +242,7 @@ describe('MessageItem arms', () => { } as never} />, ) - expect(view.getByText('插话')).toBeTruthy() + expect(view.queryByText('插话')).toBeNull() expect(view.getByText('steer!')).toBeTruthy() expect(view.getByText(/附加内容块/)).toBeTruthy() fireEvent.click(view.getByRole('button', { name: '复制' })) diff --git a/packages/client/ui-conversation/tests/chat-store.spec.ts b/packages/client/ui-conversation/tests/chat-store.spec.ts index 17d90cfbde..79bdd6ecaa 100644 --- a/packages/client/ui-conversation/tests/chat-store.spec.ts +++ b/packages/client/ui-conversation/tests/chat-store.spec.ts @@ -25,8 +25,6 @@ describe('createChatStore', () => { store.actions.setDraft('hello') expect(store.store.getSnapshot().draft).toBe('hello') - store.actions.clearDraft() - expect(store.store.getSnapshot().draft).toBe('') store.actions.setView('chat') expect(store.store.getSnapshot().view).toBe('chat') @@ -37,17 +35,6 @@ describe('createChatStore', () => { expect(store.store.getSnapshot().inspect).toBeNull() }) - it('restoreDraft only fills an empty draft (optimistic-send rollback contract)', () => { - const store = createChatStore().create() - // Rollback path: draft was cleared by send, nothing typed since. - store.actions.restoreDraft('failed text') - expect(store.store.getSnapshot().draft).toBe('failed text') - // The user typed something new before the failure landed: keep theirs. - store.actions.setDraft('newer input') - store.actions.restoreDraft('stale text') - expect(store.store.getSnapshot().draft).toBe('newer input') - }) - it('persists per scope key and rehydrates a fresh instance', () => { const handle = createChatStore() const s1 = handle.create('sess-1') diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6b2072ddfb..d6b4996567 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -261,7 +261,13 @@ function makeHarness(init?: Partial) { useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined), useInput: (() => { throw new Error('unused') }), - inputActions: { setDraft: () => {}, submit: () => {} }, + inputActions: { + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }, useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, @@ -269,6 +275,7 @@ function makeHarness(init?: Partial) { openDetails, openFile, loadOlder, + loadImage: vi.fn(() => Promise.reject(new Error('not used'))), inspectCall, chatScroll, forkAt, @@ -459,9 +466,6 @@ describe('ChatView', () => { expect(view.queryByText('later')).toBeNull() const pendingBubble = view.getByText('interrupt now').closest('[data-pending-steering]') expect(pendingBubble).not.toBeNull() - // Pending and durable steering carry the same interjection caption, so the - // hand-off does not change what the row says it is. - expect(within(pendingBubble as HTMLElement).getByText('插话')).toBeTruthy() fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' })) expect(writeText).toHaveBeenCalledWith('interrupt now') expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull() @@ -483,7 +487,6 @@ describe('ChatView', () => { }) expect(view.getAllByText('interrupt now')).toHaveLength(1) expect(view.container.querySelector('[data-pending-steering]')).toBeNull() - expect(view.getAllByText('插话')).toHaveLength(1) // Only the durable steering bubble: the turn is still running, so its // assistant narration owns no footer yet, and a steering bubble never // carries a branch action. diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index ff3a0f1598..3acdc44a2a 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -122,7 +122,13 @@ describe('render branch tails', () => { useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} @@ -173,7 +179,13 @@ describe('render branch tails', () => { useWorkspaces={bindSnapshotSelector(emptyWorkspaces)} useProjection={(() => undefined)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useStore={bindSnapshotSelector(chat)} actions={chat.actions} closeDetails={vi.fn()} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 2c1cf33ad7..59c0f9eefa 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -12,6 +12,8 @@ import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SessionInputShell } from '../src/client/input/facade.ts' +import type { ComposerAttachment } from '../src/client/contract/slots.ts' +import type { DraftAttachmentId } from '../src/client/input/contract.ts' import { InputBar } from '../src/client/skeleton/InputBar.tsx' import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx' import { zh } from '../src/client/locales.ts' @@ -69,6 +71,8 @@ interface BenchOptions { overlay?: React.ReactNode leftItems?: React.ReactNode rightItems?: React.ReactNode + attachments?: readonly ComposerAttachment[] + addImages?: (files: readonly File[]) => string | null commandMenuOpen?: boolean busyEnter?: 'queue' | 'steer' toggleCommandMenu?: (selection: { start: number; end: number }) => void @@ -113,7 +117,9 @@ function bench(over?: BenchOptions) { : {}), }) if (over?.draft !== undefined && over.draft !== '') shell.setDraft(over.draft) + if (over?.attachments !== undefined) shell.addImages(over.attachments.map(attachment => attachment.id)) const stop = vi.fn() + const removeImage = vi.fn((id: DraftAttachmentId) => { shell.removeImage(id) }) const menuLauncher = createSnapshotStore(over?.commandMenuOpen === true ? 'command' : null) const slotCalls: { key: string; owner: unknown }[] = [] const renderSlot = ((key: string, owner: object) => { @@ -139,6 +145,12 @@ function bench(over?: BenchOptions) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + addImages: over?.addImages ?? (() => null), + removeImage, + draftImages: ids => ids.flatMap((id) => { + const attachment = over?.attachments?.find(candidate => candidate.id === id) + return attachment === undefined ? [] : [attachment] + }), resolveSubmitMode: (running, gesture, steeringAvailable) => { if (!running || !steeringAvailable) return 'queue' const preferred = over?.busyEnter ?? 'queue' @@ -168,11 +180,66 @@ function bench(over?: BenchOptions) { )! const interruptButton = view.container.querySelector('button[aria-label="停止生成"]') return { - view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, slotCalls, menuLauncher, + view, textarea, button, interruptButton, props, sink, shell, wiring: shell, session, stop, removeImage, slotCalls, + menuLauncher, steerQueue: over?.steerQueue, } } +describe('image draft rail', () => { + it('collects clipboard files while preserving text from a mixed paste', () => { + const addImages = vi.fn(() => null) + const { textarea, shell } = bench({ addImages }) + const image = new File([Uint8Array.of(1, 2, 3)], 'pixel.png', { type: 'image/png' }) + fireEvent.paste(textarea, { + clipboardData: { + items: [ + { kind: 'string', type: 'text/plain', getAsFile: () => null }, + { kind: 'file', type: 'image/png', getAsFile: () => image }, + ], + getData: () => '同时粘贴的文字', + }, + }) + expect(addImages).toHaveBeenCalledWith([image]) + expect(shell.snapshot.draft).toBe('同时粘贴的文字') + }) + + it('accepts file drops and prevents browser navigation', () => { + const addImages = vi.fn(() => null) + const { view } = bench({ addImages }) + const card = view.container.querySelector('[class*="card"]')! + const image = new File([Uint8Array.of(1)], 'dropped.png', { type: 'image/png' }) + const dataTransfer = { types: ['Files'], files: [image], dropEffect: 'none' } + expect(fireEvent.dragEnter(card, { dataTransfer })).toBe(false) + expect(view.getByRole('status').textContent).toContain('松开以添加图片') + expect(fireEvent.dragOver(card, { dataTransfer })).toBe(false) + expect(dataTransfer.dropEffect).toBe('copy') + expect(fireEvent.drop(card, { dataTransfer })).toBe(false) + expect(addImages).toHaveBeenCalledWith([image]) + }) + + it('sends an image-only draft and removes its thumbnail', () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } + const { view, textarea, sink, removeImage } = bench({ attachments: [attachment] }) + expect((view.getByRole('button', { name: '发送消息' }) as HTMLButtonElement).disabled).toBe(false) + fireEvent.keyDown(textarea, { key: 'Enter' }) + expect(sink).toHaveBeenCalledWith('', ['draft-1'], 'queue') + fireEvent.click(view.getByRole('button', { name: '移除图片 pixel.png' })) + expect(removeImage).toHaveBeenCalledWith('draft-1') + }) + + it('opens the original image on double-click and closes it with Escape', () => { + const file = new File([Uint8Array.of(1)], 'pixel.png', { type: 'image/png' }) + const attachment = { kind: 'image' as const, id: 'draft-1' as DraftAttachmentId, file, previewUrl: 'blob:draft-1' } + const { view } = bench({ attachments: [attachment] }) + fireEvent.doubleClick(view.getByTitle('双击查看原图')) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.keyDown(window, { key: 'Escape' }) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) +}) + describe('Enter semantics', () => { it('advertises the empty-draft whole-queue steering gesture when it is available', () => { const { textarea } = bench({ running: true, queue: [row('q-1')], steerQueue: vi.fn() }) @@ -228,7 +295,7 @@ describe('Enter semantics', () => { it('plain Enter submits queue mode through the machine; repeat and empty are suppressed', () => { const { textarea, sink } = bench({ draft: 'hello' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('hello', 'queue') + expect(sink).toHaveBeenCalledWith('hello', [], 'queue') fireEvent.keyDown(textarea, { key: 'Enter', repeat: true }) expect(sink).toHaveBeenCalledTimes(1) const empty = bench({ draft: ' ' }) @@ -253,15 +320,15 @@ describe('Enter semantics', () => { it('Ctrl/Meta+Enter sends normally while idle and steers while running', () => { const idle = bench({ draft: 'hello' }) fireEvent.keyDown(idle.textarea, { key: 'Enter', metaKey: true }) - expect(idle.sink).toHaveBeenCalledWith('hello', 'queue') + expect(idle.sink).toHaveBeenCalledWith('hello', [], 'queue') const busyCtrl = bench({ running: true, draft: 'steer with ctrl' }) fireEvent.keyDown(busyCtrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', 'steer') + expect(busyCtrl.sink).toHaveBeenCalledWith('steer with ctrl', [], 'steer') const busyMeta = bench({ running: true, draft: 'steer with cmd' }) fireEvent.keyDown(busyMeta.textarea, { key: 'Enter', metaKey: true }) - expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', 'steer') + expect(busyMeta.sink).toHaveBeenCalledWith('steer with cmd', [], 'steer') }) it('empty-draft Cmd/Ctrl+Enter steers the whole queue instead of submitting', () => { @@ -326,7 +393,7 @@ describe('Enter semantics', () => { const steerQueue = vi.fn() const { textarea, sink } = bench({ running: true, queue: [row('q-1')], draft: '插话', steerQueue }) fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true }) - expect(sink).toHaveBeenCalledWith('插话', 'steer') + expect(sink).toHaveBeenCalledWith('插话', [], 'steer') expect(steerQueue).not.toHaveBeenCalled() }) @@ -374,7 +441,7 @@ describe('running and lock semantics', () => { expect(textarea.disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队消息2' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队消息2', 'queue') + expect(sink).toHaveBeenCalledWith('排队消息2', [], 'queue') expect(button.getAttribute('aria-label')).toBe('停止生成') fireEvent.click(button) expect(stop).toHaveBeenCalledTimes(1) @@ -383,17 +450,17 @@ describe('running and lock semantics', () => { it('running plain Enter follows the busy-state Steer preference', () => { const { textarea, sink } = bench({ running: true, busyEnter: 'steer', draft: '直接插话' }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('直接插话', 'steer') + expect(sink).toHaveBeenCalledWith('直接插话', [], 'steer') }) it('running Cmd/Ctrl+Enter uses the opposite of the busy-state Enter preference', () => { const meta = bench({ running: true, busyEnter: 'steer', draft: '排到下一轮' }) fireEvent.keyDown(meta.textarea, { key: 'Enter', metaKey: true }) - expect(meta.sink).toHaveBeenCalledWith('排到下一轮', 'queue') + expect(meta.sink).toHaveBeenCalledWith('排到下一轮', [], 'queue') const ctrl = bench({ running: true, busyEnter: 'steer', draft: 'also queue' }) fireEvent.keyDown(ctrl.textarea, { key: 'Enter', ctrlKey: true }) - expect(ctrl.sink).toHaveBeenCalledWith('also queue', 'queue') + expect(ctrl.sink).toHaveBeenCalledWith('also queue', [], 'queue') }) it('running continuable subagent keeps Send beside an independent Stop', () => { @@ -413,7 +480,7 @@ describe('running and lock semantics', () => { expect(interruptButton).not.toBeNull() expect(textarea.disabled).toBe(false) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('后续消息', 'queue') + expect(sink).toHaveBeenCalledWith('后续消息', [], 'queue') fireEvent.click(interruptButton!) expect(stop).toHaveBeenCalledTimes(1) }) @@ -470,11 +537,11 @@ describe('running and lock semantics', () => { } const plain = bench({ running: true, busyEnter: 'steer', draft: 'plain', subagent }) fireEvent.keyDown(plain.textarea, { key: 'Enter' }) - expect(plain.sink).toHaveBeenCalledWith('plain', 'queue') + expect(plain.sink).toHaveBeenCalledWith('plain', [], 'queue') const accelerated = bench({ running: true, draft: 'accelerated', subagent }) fireEvent.keyDown(accelerated.textarea, { key: 'Enter', metaKey: true }) - expect(accelerated.sink).toHaveBeenCalledWith('accelerated', 'queue') + expect(accelerated.sink).toHaveBeenCalledWith('accelerated', [], 'queue') }) it('disabled (session removed) locks the textarea and chrome', () => { @@ -487,7 +554,7 @@ describe('running and lock semantics', () => { it('idle primary sends and disables on empty draft', () => { const { button, sink } = bench({ draft: 'go' }) fireEvent.click(button) - expect(sink).toHaveBeenCalledWith('go', 'queue') + expect(sink).toHaveBeenCalledWith('go', [], 'queue') const empty = bench() expect(empty.button.disabled).toBe(true) }) @@ -612,7 +679,7 @@ describe('running and lock semantics', () => { } // Pasted text lands below the fold: scroll down by exactly the overshoot. caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'pasted' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'pasted' } }) await settle() expect(scroll.scrollTop).toBe(88) // 524 - 436 // Measured on the mirror's own text, at the index the paste left the caret @@ -621,12 +688,12 @@ describe('running and lock semantics', () => { expect(measured!.offset).toBe('pasted'.length) // A caret already inside the box does not move it. caretAt(200) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'more' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'more' } }) await settle() expect(scroll.scrollTop).toBe(88) // Above the fold (a cut can leave it there): scroll back up. caretAt(60) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'again' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'again' } }) await settle() expect(scroll.scrollTop).toBe(48) // 88 - (100 - 60) // A caret straight after a newline has nothing on its line to measure, so @@ -634,7 +701,7 @@ describe('running and lock semantics', () => { // chromium reports no client rects at all for the collapsed position. mirror.style.lineHeight = '24px' caretAt(500) - fireEvent.paste(textarea, { clipboardData: { getData: () => 'block\n' } }) + fireEvent.paste(textarea, { clipboardData: { items: [], getData: () => 'block\n' } }) await settle() // The four pastes accumulate at the draft's head, so the caret is at the // end of what they inserted — and the measured index is the newline before it. diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index dc7bca56d4..09cfe4ca66 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -48,6 +48,9 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + addImages: () => null, + removeImage: () => {}, + draftImages: () => [], resolveSubmitMode: () => 'queue', toggleCommandMenu: vi.fn(), useNotices: bindSnapshotSelector(shell.notices), @@ -90,7 +93,7 @@ describe('matrix row: plain', () => { fireEvent.change(textarea, { target: { value: '普通消息' } }) expect(shell.snapshot.claim).toBeUndefined() fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('普通消息', 'queue') + expect(sink).toHaveBeenCalledWith('普通消息', [], 'queue') expect(shell.snapshot.phase).toBe('plain') }) }) @@ -189,7 +192,7 @@ describe('matrix row: locked (session disabled)', () => { expect((textarea).disabled).toBe(false) fireEvent.change(textarea, { target: { value: '排队' } }) fireEvent.keyDown(textarea, { key: 'Enter' }) - expect(sink).toHaveBeenCalledWith('排队', 'queue') + expect(sink).toHaveBeenCalledWith('排队', [], 'queue') }) }) diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index d5a34327f9..ad268611e2 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -134,6 +134,9 @@ async function scopedBench(register?: (slash: SlashService) => void) { useInput: bindSnapshotSelector(shell.state), inputActions: shell.actions, keyboard: shell, + addImages: () => null, + removeImage: () => {}, + draftImages: () => [], resolveSubmitMode: () => 'queue', toggleCommandMenu: (selection) => { const snapshot = shell.snapshot @@ -237,7 +240,7 @@ describe('scenario D: execute-kind /compact', () => { act(() => { b2.shell.setDraft('/compact 现在') }) fireEvent.keyDown(b2.textarea, { key: 'Enter' }) // execute with trailing → matchEnter answers undefined → default sink. - await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', 'queue') }) + await vi.waitFor(() => { expect(b2.sink).toHaveBeenCalledWith('/compact 现在', [], 'queue') }) expect(b2.executed).toHaveLength(0) }) }) @@ -291,7 +294,7 @@ describe('scenario I: unknown /xyz + enter', () => { const b = await bench() act(() => { b.shell.setDraft('/xyz 干点啥') }) fireEvent.keyDown(b.textarea, { key: 'Enter' }) - await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', 'queue') }) + await vi.waitFor(() => { expect(b.sink).toHaveBeenCalledWith('/xyz 干点啥', [], 'queue') }) expect(b.shell.snapshot.phase).toBe('plain') expect(b.execute).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/message-image.spec.tsx b/packages/client/ui-conversation/tests/message-image.spec.tsx new file mode 100644 index 0000000000..6da4d42f12 --- /dev/null +++ b/packages/client/ui-conversation/tests/message-image.spec.tsx @@ -0,0 +1,81 @@ +// @vitest-environment jsdom + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' +import { MessageImage } from '../src/client/chat/MessageImage.tsx' +import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' +import { en, zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const t = makeTranslate(zh, commonZh) +const enT = makeTranslate(en, commonZh) + +const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 68, + width: 640, + height: 320, + name: 'history.png', +} + +describe('MessageImage', () => { + it('loads a session-authorized URL, bounds the thumbnail, and double-clicks into the original', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png,双击查看原图' }) + expect(frame.getAttribute('style')).toContain('width: 240px') + expect(frame.getAttribute('style')).toContain('height: 120px') + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledWith(attachment) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: '原图预览' })).toBeTruthy() + fireEvent.click(view.getByRole('button', { name: '关闭原图预览' })) + expect(view.queryByRole('dialog', { name: '原图预览' })).toBeNull() + }) + + it('surfaces a retry control when durable bytes cannot be read', async () => { + const load = vi.fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce('blob:retry') + const view = render() + const retry = await view.findByRole('button', { name: '图片加载失败,点击重试' }) + fireEvent.click(retry) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + expect(load).toHaveBeenCalledTimes(2) + }) + + it('renders image controls from the active English dictionary', async () => { + const load = vi.fn().mockResolvedValue('blob:history') + const view = render() + const frame = view.getByRole('button', { name: 'history.png, double-click to view original' }) + await waitFor(() => { expect(view.getByAltText('history.png')).toBeTruthy() }) + fireEvent.doubleClick(frame) + expect(view.getByRole('dialog', { name: 'Original image preview' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Close original image preview' })).toBeTruthy() + }) + + it('keeps assistant images at their original position between text blocks', async () => { + const view = render( + Promise.resolve('blob:middle')} + />, + ) + const image = await view.findByAltText('history.png') + const before = view.getByText('before') + const after = view.getByText('after') + expect(before.compareDocumentPosition(image) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + expect(image.compareDocumentPosition(after) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0) + }) +}) diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 3a04214298..68170b604f 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -61,7 +61,8 @@ function liveSession(initial: ConversationSnapshot) { } } -const INPUT_STATE: InputState = { draft: '', draftRev: 0, phase: 'plain', occurrences: [], queue: [] } +/** InputZone owner stub (the dock reads useSession only; the zone fields satisfy the owner share). */ +const INPUT_STATE: InputState = { draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [] } // Standard locale seat stub mirroring the real ns → common → key chain. const t: QueueDockProps['t'] = makeTranslate(zh, commonZh) diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 894c58c4ca..8454aa35c8 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -5,15 +5,15 @@ // tag probe). import { Context } from 'cordis' import { describe, expect, it, vi } from 'vitest' -import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' -import type { QueuedMessage } from '@deepseek-ai/dsh-client-runtime/client' -import { ConversationService } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' +import type { QueuedMessage, SessionFace } from '@deepseek-ai/dsh-client-runtime/client' import { ComposerBlockRegistry } from '../src/client/input/blocks.ts' import { InputHub } from '../src/client/input/hub.ts' +import { ConversationService, UnsupportedImageMediaTypeError } from '../src/client/service.ts' import { zh } from '../src/client/locales.ts' -async function bench() { +async function bench(readAttachment?: SessionFace['readAttachment']) { const runtime = await SlotTestRuntime.create() const prompt = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) const updateQueue = vi.fn(() => Promise.resolve({ ok: true as const, value: { accepted: true as const } })) @@ -21,7 +21,7 @@ async function bench() { const loadOlder = vi.fn(() => Promise.resolve()) await runtime.sessions.add({ id: 's1', - session: { prompt, updateQueue, cancel, loadOlder }, + session: { prompt, updateQueue, cancel, loadOlder, ...(readAttachment === undefined ? {} : { readAttachment }) }, }) // config.input is required (the apply shares its hub with the inject // factories); the bench passes its own instance explicitly. @@ -34,7 +34,7 @@ async function bench() { const root = runtime.ctx.get('conversation') as ConversationService const scoped = runtime.sessions.scope('s1')!.get('conversation') as ConversationService const shell = hub.shellFor(runtime.sessions.binding('s1')!) - return { runtime, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } + return { runtime, fiber, root, scoped, hub, shell, prompt, updateQueue, cancel, loadOlder } } describe('ConversationService', () => { @@ -83,6 +83,52 @@ describe('ConversationService', () => { await b.runtime.dispose() }) + it('releases draft previews when their session scope is disposed', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:draft-1') + const revoked = vi.spyOn(URL, 'revokeObjectURL').mockReturnValue(undefined) + try { + const [attachment] = b.root.createDraftImages([ + new File([new Uint8Array(4)], 'a.png', { type: 'image/png' }), + ]) + if (attachment === undefined) throw new Error('draft attachment missing') + b.root.input.for(b.runtime.sessions.scope('s1')!).addImages([attachment.id]) + await b.runtime.sessions.remove('s1') + expect(b.root.draftImages([attachment.id])).toEqual([]) + expect(revoked).toHaveBeenCalledWith('blob:draft-1') + } finally { + created.mockRestore() + revoked.mockRestore() + } + await b.runtime.dispose() + }) + + it('validates every MIME type before allocating previews', async () => { + const b = await bench() + const created = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:preview') + expect(() => b.root.createDraftImages([ + new File([Uint8Array.of(1)], 'valid.png', { type: 'image/png' }), + new File([Uint8Array.of(2)], 'invalid.svg', { type: 'image/svg+xml' }), + ])).toThrow(UnsupportedImageMediaTypeError) + expect(created).not.toHaveBeenCalled() + created.mockRestore() + await b.runtime.dispose() + }) + + it('invalidates pending historical image loads when the rendered session is released', async () => { + const read = Promise.withResolvers>>() + const b = await bench(() => read.promise) + const sessionId = b.runtime.sessions.behavior('s1').sessionId + const attachment = { + attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + } as const + const pending = b.root.resolveImage(sessionId, attachment) + b.root.releaseSessionImages(sessionId) + read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) + await expect(pending).rejects.toThrow('historical image scope was released') + await b.runtime.dispose() + }) + it('fails loudly from the root scope, on an unbound session, or without SessionsService', async () => { const b = await bench() await expect(b.root.send('x')).rejects.toThrow(/requires a session scope/) diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index e7c9729685..7d7596a49e 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -179,6 +179,7 @@ function mount( subscribe: () => () => {}, version: () => 1, }} + releaseSessionImages={vi.fn()} bindDraftMirror={write => wiring.bindMirror(write)} /> ) @@ -198,6 +199,9 @@ function mount( useInput={useInput} inputActions={inputActions} keyboard={wiring} + addImages={() => null} + removeImage={() => {}} + draftImages={() => []} resolveSubmitMode={() => 'queue'} toggleCommandMenu={vi.fn()} useNotices={bindSnapshotSelector(wiring.notices)} @@ -301,7 +305,7 @@ describe('ConversationRoot resident composer', () => { fireEvent.change(box, { target: { value: 'ordinary revised' } }) expect(b.chat.store.getSnapshot().draft).toBe('ordinary revised') fireEvent.keyDown(box, { key: 'Enter' }) - expect(b.sink).toHaveBeenCalledWith('ordinary revised', 'queue') + expect(b.sink).toHaveBeenCalledWith('ordinary revised', [], 'queue') expect((b.view.getByRole('button', { name: 'Child' }) as HTMLButtonElement).disabled).toBe(true) expect(b.view.queryByText('Root')).toBeNull() }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 0d6f250441..115dee1f1e 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -8,6 +8,12 @@ "src" ], "references": [ + { + "path": "../../attachment/attachment" + }, + { + "path": "../../util/brand" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 0cea47b572..600949f5e8 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -331,7 +331,13 @@ describe('DetailsPanel diff Output section', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index f74bbae6be..012ae754b2 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -278,7 +278,13 @@ describe('DetailsPanel Output section (read)', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 0016318b90..b41b665ea8 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -393,7 +393,13 @@ describe('DetailsPanel Output section (search)', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index 0dcb59d818..a868ffa744 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -468,7 +468,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} @@ -653,7 +653,7 @@ describe('DetailsPanel Output section', () => { baselinesReady: true, recentWorkspaceId: undefined, }))} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ setDraft: () => {}, addImages: () => true, removeImage: () => {}, pruneImages: () => {}, submit: () => {} }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 44f9e05090..927b760b82 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -223,7 +223,13 @@ describe('DetailsPanel web Output section', () => { useSessions={bindSnapshotSelector(sessions)} useWorkspaces={bindSnapshotSelector(workspaces)} useInput={(() => { throw new Error('unused') })} - inputActions={{ setDraft: () => {}, submit: () => {} }} + inputActions={{ + setDraft: () => {}, + addImages: () => true, + removeImage: () => {}, + pruneImages: () => {}, + submit: () => {}, + }} useProjection={(() => undefined)} useStore={bindSnapshotSelector(chat)} actions={chat.actions} diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index ff63b860b9..6d77f907f1 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -46,6 +46,7 @@ function timelineBlock(block: AssistantBlock): AssistantBlock { switch (block.kind) { case 'text': return { kind: 'text', text: '' } case 'reasoning': return { kind: 'reasoning', text: '' } + case 'image': return block case 'tool-call': return { kind: 'tool-call', callId: block.callId, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 6e86fac8dc..0d7a8c9e07 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -736,6 +736,12 @@ function assistantSourceBlock(block: AssistantBlock): TrajectorySourceBlock { callId: block.callId, toolName: block.name, } + // Attachment refs carry no fetchable bytes, so the record shows the + // durable metadata instead of an inline preview. + case 'image': return { + type: 'image', + content: stringifySourceValue(block.attachment), + } case 'other': return sourceBlock(block.block) } } diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2a0ec68ec0..c9952be580 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -194,8 +194,12 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES subscribe: (fn: () => void) => slots.subscribe('conversation.view', fn), version: () => slots.getVersion('conversation.view'), } - const useInput = bindSnapshotSelector(createSnapshotStore({ draft: '', draftRev: 0, phase: 'plain', queue: [] })) as never - const inputActions = { setDraft: vi.fn(), submit: vi.fn() } + const useInput = bindSnapshotSelector(createSnapshotStore({ + draft: '', imageIds: [], draftRev: 0, phase: 'plain', occurrences: [], queue: [], + })) as never + const inputActions = { + setDraft: vi.fn(), addImages: vi.fn(), removeImage: vi.fn(), pruneImages: vi.fn(), submit: vi.fn(), + } // Minimal outlet twin: resolve the ring entry by the `only` filter and // render it with the session standard kit (what SlotOutlet does for a // list-kind session slot, minus machinery). @@ -256,6 +260,7 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES actions={chat.actions} renderSlot={renderSlot} views={views} + releaseSessionImages={vi.fn()} useInput={useInput} inputActions={inputActions} bindDraftMirror={() => () => {}} diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index f44b45245d..75e9e7be19 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md -README.md: e0e3413d42e6afb8e95f91d79f9bdf3a1f17fbda -README.zh.md: 4515592710f855dc713f078479ebe7c9c7299de1 +README.md: 97c71cafe1eba7dd29c903535b378439586e807e +README.zh.md: 55a0080fe3c08e5559d737d46f57e23645a52cad diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index e0e3413d42..97c71cafe1 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -15,7 +15,7 @@ This backend owns the compaction policy: - **Model-free pruning** — after pressure or canonical overflow qualifies, the optional [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) service rewrites oversized tool results before range selection. Compact-basic remeasures through `ctx.tokenMeter`, skips summarization when pressure becomes safe, and otherwise summarizes the pruned surface. Below-pressure step checks never prune. - **Retention** — compact the oldest whole surface units while preserving a recent tail and balanced tool-call/result cuts through the [`dsh-compact` boundary helpers](../compact/README.md#tool-pairing-boundaries). Turn boundaries do not protect old steps inside a runaway turn. An open indivisible tail declines until it closes. The optional pruner can repair an oversized closed tool unit when its text-bearing result is the removable bulk; indivisible non-tool units and non-prunable tool remainders remain out of scope. - **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold. -- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` extension point. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call. +- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` extension point. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim, including image references, and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. The selected adapter must resolve or explicitly reject those images. It sets `GenerateOptions.purpose` to `compaction`, which adapters may forward as request attribution (the DeepSeek adapter sends `x-deepseek-harness-compact: 1`) without touching the model-visible body. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call; image output fails with `UNSUPPORTED_CONTENT` rather than disappearing. - **Framing** — the replacement user message marks established checkpoint context with `` tags. The raw summary remains on the `compact/summary` event, and later automatic cycles merge the prior checkpoint. - **Lifecycle** — all entry points share one bracket-first region transaction. It validates the range and live lock, appends `compact/start` synchronously, prepares and awaits the summary, revalidates, appends `compact/summary` plus the replacement, and makes exactly one closing attempt. Automatic and explicit-region calls require a numeric open-turn owner and whole-surface stability; the serial `agent/pre-step` listener checks pressure before request derivation, while canonical provider overflow enters through `agent/request-error` and authorizes retry only after durable surface progress. `compactNow()` reserves idle admission, uses `turn: null`, accepts append-only context outside its selected span, flushes every closed attempt, and releases admission in `finally`. - **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure. diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index 4515592710..55a0080fe3 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -15,7 +15,7 @@ - **不依赖模型的剪枝**:在压力或规范溢出符合条件后,可选的 [`ctx.toolResultPrune`](../compact-tool-result-prune/README.md) 服务会在选择范围之前改写超大工具结果。Compact-basic 通过 `ctx.tokenMeter` 重新测量;如果压力已回到安全范围,就跳过摘要,否则对已剪枝的表层进行摘要。低于压力的步骤检查绝不剪枝。 - **保留**:压缩最旧的完整表层单元,同时保留近期尾部,并通过 [`dsh-compact` 边界 helper](../compact/README.md#tool-pairing-boundaries) 将切分点调整到工具调用/结果配对平衡的位置。轮次边界不会保护失控轮次内的旧步骤。尚未闭合且不可分的尾部会在闭合前拒绝压缩。当闭合的超大工具单元以文本型结果为可移除主体时,可选 pruner 可以修复它;不可分的非工具单元与不可剪枝的工具剩余部分不在范围内。 - **收敛**:最多按 `compactionRetries` 重试头部检查点压缩;拒绝不能缩小源内容的摘要,如果重试仍无法回到阈值以下,则抛出异常。 -- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息,并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用。 +- **摘要**:直接 `llm/stream` 调用使用已配置的提供方/模型对与上限,回退到最新已记录请求目标,然后再回退到 agent 目标,而不运行仅用于 agent loop 的 `agent/request` 扩展点。该调用会逐字回放会话自身的系统提示词、工具与已遮蔽区域消息(包括图片引用),并将压缩指令作为最后一条 user 消息追加,从而复用提供方的热前缀 cache,而非使它失效。所选适配器必须解析或明确拒绝这些图片。它将 `GenerateOptions.purpose` 设为 `compaction`,适配器可将其作为请求归因转发(DeepSeek 适配器发送 `x-deepseek-harness-compact: 1`),但不会触碰模型可见的请求体。只有返回的文本会进入检查点;推理(reasoning)和工具调用都会被排除,以免泄露私有推理或产生遗留调用;图片输出会以 `UNSUPPORTED_CONTENT` 失败,而不是消失。 - **框定**:替换 user 消息使用 `` 标签标记已建立的检查点上下文。原始摘要保留在 `compact/summary` 事件上,后续自动周期会合并之前的检查点。 - **生命周期**:所有入口点共享一个先记录标记的区域事务。它会验证范围与活动锁,同步追加 `compact/start`,准备并等待摘要,重新验证,再追加 `compact/summary` 和替换,最后恰好进行一次闭合尝试。自动调用和显式范围调用要求数字标识的开放轮次归属,并要求整个表层保持稳定;串行 `agent/pre-step` listener 会在派生请求之前检查压力,而规范提供方溢出则经由 `agent/request-error` 进入,并且只在表层取得持久进展后才允许重试。`compactNow()` 会预留空闲接纳,使用 `turn: null`,允许所选 span 之外追加仅追加上下文,flush 每次已闭合尝试,并在 `finally` 中释放接纳预留。 - **溢出恢复**:提供方已确认的溢出不需容量元数据。它会绕过常规压力与保留,执行剪枝,再尝试一次最大平衡头部缩减,并留下最新不可分单元。只要 `surface.replaceGeneration` 前进,就允许重试,包括剪枝在后续摘要工作抛出异常前已落地的情况。如果没有替换、目标特定上限已耗尽、已取消,或遇到未知/非规范错误,则保留原始提供方失败。 diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index bed4dedb69..681919905e 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -5,7 +5,7 @@ */ import type { Context } from 'cordis' -import { createUserMessage, BlockAssembler } from '@deepseek-ai/dsh-llm' +import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema, } from '@deepseek-ai/dsh-llm' @@ -166,7 +166,7 @@ export async function summarizeWithLlm( if (error !== undefined) throw error const rawOutput = assembler.blocks() - const summary = textOnly(rawOutput) + const summary = summaryText(rawOutput) if (!summary.some(block => block.text.trim().length > 0)) { throw new Error('summarization produced no text summary content') } @@ -213,9 +213,12 @@ function finishError(finish: FinishReason): Error | undefined { } } -/** Keep only text blocks before synthesizing a user message. */ -function textOnly( +/** Reject visual output and keep only text before synthesizing a user message. */ +function summaryText( blocks: readonly ContentBlock[], ): Array> { + if (contentHasImage(blocks)) { + throw new LlmError('compaction summary cannot contain image output', 'UNSUPPORTED_CONTENT') + } return blocks.filter((block): block is Extract => block.type === 'text') } diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index f97aaf52b9..b3e088c82a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { Context } from 'cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' import { selectCompactableRange } from '@deepseek-ai/dsh-compact-basic/src/region.ts' @@ -1222,7 +1223,19 @@ describe('default one-shot summarizer', () => { const { adapter, compact } = await summarizerHarness([{ type: 'text', text: 'summary' }]) const tools = [{ name: 'do_thing', description: 'd', parameters: { type: 'object' } }] const prefix: Message = createUserMessage({ - content: [{ type: 'text', text: 'earlier turn' }], + content: [ + { type: 'text', text: 'earlier turn' }, + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + ], source: { kind: 'plugin', plugin: 'test' }, }) await compact.runSummarize({ @@ -1382,6 +1395,43 @@ describe('default one-shot summarizer', () => { await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) .rejects.toThrow(/no text summary content/) }) + + it('rejects image summary output instead of silently dropping it', async () => { + const { compact } = await summarizerHarness([ + { + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }, + { type: 'text', text: 'partial summary' }, + ]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + + it('rejects image summary output nested in a tool result', async () => { + const { compact } = await summarizerHarness([{ + type: 'tool-result', + toolCallId: CallId('summary-tool'), + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + }, + }], + }]) + await expect(compact.runSummarize(promptInput('history'), agent(conversation(1), MODEL))) + .rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) }) describe('automatic listener and loader composition', () => { diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 79b90c87f5..4fe2b7806e 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/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/host/apiproxy/README.md -README.md: 0e98520149297732da8a4d06608f9b04204a444f -README.zh.md: 27dc0ad668bd2da3d340fd081d698a7d090a94d4 +README.md: d5afd21033afd8291c8059fb225deee3965f8b65 +README.zh.md: cde0b4fd286579f5b389bc601750ca8303b35f15 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 0e98520149..d5afd21033 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,11 +2,11 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{nativeOpen?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers such as HTTP wrap `ctx.apiProxy` themselves. The shipped Web composition lives in [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml), while its default Agent model selection belongs to [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md) in the base bundle. ## The shared Agent default (`agent-default-model` Settings section) -`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. `workspaceRoot` remains ApiProxy config because it is a Host launcher fact, not a model preference. +`ApiProxyService` consumes `ctx.agentDefaultModel`; it does not own a provider/model config or settings section. The shared service registers `{provider, model, reasoningEffort?}` under `agent-default-model`: the base bundle's composition entry is the lower layer and `settings.yaml` layers the user's choice over it. A session resolves its model selection from three tiers on every access: a selection made in this process, otherwise the session's latest logged `request/header`, otherwise this default. A session that has run a turn derives its selection from its log, while a blank session observes a default saved after it was created. @@ -36,7 +36,7 @@ Session model selection is a session-domain contract. `session.models` returns t Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. -Workspace and Session lists are separate reconnect baselines. `workspace.create({ name })` creates a uniquely titled directory under the configured root, while `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. +Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 27dc0ad668..cde0b4fd28 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,11 +2,11 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{workspaceRoot?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 +所有客户端形态共用的 API 网关:TS 约定(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{nativeOpen?}`,提供 `ctx.apiProxy`)。该包在设计上与传输方式无关,不注册任何路由;HTTP 等载体自行包装 `ctx.apiProxy`。随发行版交付的 Web 组合位于 [`packages/bundle/web-app/cordis.patch.yml`](../../bundle/web-app/cordis.patch.yml),其默认 Agent(智能体)模型选择属于 base 组合包中的 [`@deepseek-ai/dsh-agent-default-model`](../../core/agent-default-model/README.md)。 ## 共享 Agent 默认值(`agent-default-model` Settings 分节) -`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。`workspaceRoot` 仍属于 ApiProxy 配置,因为它是 Host 启动器事实,而不是模型偏好。 +`ApiProxyService` 消费 `ctx.agentDefaultModel`;它不持有提供方/模型配置或 Settings 分节。共享服务在 `agent-default-model` 下注册 `{provider, model, reasoningEffort?}`:base 组合包的组合条目是底层,`settings.yaml` 把用户选择叠加其上。 会话每次访问时都按三级解析模型选择:本进程内作出的选择,其次是该会话日志中最新的 `request/header`,最后是这个默认值。已经跑过一轮的会话从自己的日志推导选择,空白会话则能观察到创建之后保存的默认值。 @@ -36,7 +36,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 -Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 +Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 06749f8f35..42f69c6211 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -38,6 +38,7 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 70ad99ee89..5576f2e75c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -5,14 +5,16 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' -import { dirname, join } from 'node:path' +import { dirname } from 'node:path' import type { Context } from 'cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' -import { createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { contentHasImage, createUserMessage, freezeMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { errorChain } from '@deepseek-ai/dsh-llm' -import type { MessageSource } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' import { isAppendSurfaceEvent, lastActivityTime } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionEventMap, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' @@ -35,7 +37,7 @@ import type {} from '@deepseek-ai/dsh-tools' import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, - ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, + ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' @@ -102,6 +104,113 @@ const COLD_SUMMARY_BATCH_SIZE = 16 /** Conversation message event types (the pagination counting unit). */ const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) +/** Decode the browser payload while rejecting non-canonical base64 forms. */ +function decodeBase64(data: string): Uint8Array { + const decoded = Buffer.from(data, 'base64') + if (data.length === 0 || decoded.toString('base64') !== data) { + throw new AttachmentError('Image upload is not canonical base64.', 'INVALID_IMAGE_BASE64') + } + return new Uint8Array(decoded) +} + +/** Validate one prompt as a batch before publishing any durable image object. */ +async function durablePromptContent(ctx: Context, content: readonly PromptContentPart[]): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const limits = ctx.attachments.imageLimits + if (content.filter(part => part.type === 'image').length > limits.maxImagesPerMessage) { + throw new AttachmentError('Prompt exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') + } + const prepared = content.map(part => part.type === 'text' + ? part + : { part, data: decodeBase64(part.data) }) + const images = prepared.filter((part): part is Extract => 'data' in part) + const totalBytes = images.reduce((sum, image) => sum + image.data.byteLength, 0) + if (totalBytes > limits.maxMessageImageBytes) { + throw new AttachmentError('Prompt exceeds the configured aggregate image-byte limit.', 'IMAGES_TOO_LARGE') + } + for (const image of images) { + await ctx.attachments.validateImage({ + data: image.data, + mediaType: image.part.mediaType, + ...image.part.name === undefined ? {} : { name: image.part.name }, + }) + } + const blocks: ContentBlock[] = [] + for (const item of prepared) { + if (!('data' in item)) { + blocks.push({ type: 'text', text: item.text }) + continue + } + const attachment = await ctx.attachments.saveImage({ + data: item.data, + mediaType: item.part.mediaType, + ...item.part.name === undefined ? {} : { name: item.part.name }, + }) + blocks.push({ type: 'image', attachment }) + } + return blocks +} + +/** Search durable content for an image reference, including nested tool results. */ +function imageBlockIn(content: unknown, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined + for (const value of content) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + if (match(ref)) return ref + } + if (block.type === 'tool-result') { + const nested = imageBlockIn(block.content, match) + if (nested !== undefined) return nested + } + } + return undefined +} + +/** Search every durable event carrier that can own model-visible content. */ +function imageInEvent(event: SessionEvent, match: (ref: ImageAttachmentRef) => boolean): ImageAttachmentRef | undefined { + const data = event.data as { + content?: unknown + message?: { content?: unknown } + inserted?: Array<{ content?: unknown }> + chunk?: { type?: unknown; block?: unknown } + } + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + if (data.message !== undefined) { + const wrapped = imageBlockIn(data.message.content, match) + if (wrapped !== undefined) return wrapped + } + if (data.inserted !== undefined) { + for (const message of data.inserted) { + const inserted = imageBlockIn(message.content, match) + if (inserted !== undefined) return inserted + } + } + if (event.type === 'assistant/chunk' && data.chunk?.type === 'block-end') { + return imageBlockIn([data.chunk.block], match) + } + return undefined +} + +/** True when the current model-visible surface contains an image. */ +function messagesHaveImage(messages: readonly { content: readonly ContentBlock[] }[]): boolean { + return messages.some(message => contentHasImage(message.content)) +} + +/** Resolve the first reference matching one opaque id. */ +function referencedImage(events: readonly SessionEvent[], attachmentId: string): ImageAttachmentRef | undefined { + for (const event of events) { + const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) + if (found !== undefined) return found + } + return undefined +} + /** * Product settings intentionally exposed beside model-provider namespaces. * @@ -403,8 +512,6 @@ export interface ApiProxyDefaults { saveDefaultModelSelection?: (selection: ModelSelection) => Promise /** Default project directory for new sessions whose create request carries no cwd. */ cwd: string - /** Parent directory for name-created workspaces. */ - workspaceRoot: string /** Native open-with-default-application; injectable for carrier tests. */ openPath?: (path: string, signal: AbortSignal) => Promise /** Native text-editor handoff; injectable for settings-document tests. */ @@ -795,9 +902,6 @@ class SessionCwdConflict extends Error { } } -/** Host failed before the registry could adopt a name-created directory. */ -class WorkspaceDirectoryCreationError extends Error {} - /** An explicit Host naming operation would duplicate another Workspace title. */ class WorkspaceNameConflictError extends Error { constructor(readonly workspaceName: string) { @@ -869,6 +973,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const pendingQuestions = new Map() const pendingApprovals = new Map() const muxQueues = new Set>>() + const imageAdmissionChains = new WeakMap>() + + /** Serialize image admission with model selection for one agent. */ + function serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) + imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) + return result + } /** * Install or return the session-local model selection that prompt assembly snapshots. @@ -1359,29 +1471,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro } /** Resolve or create one path while holding the Host's workspace-create chain. */ - function ensureWorkspace( - path: string, - title: string | undefined, - rejectExistingName = false, - createDirectory = false, - ): Promise<{ workspace: Workspace; created: boolean }> { + function ensureWorkspace(path: string): Promise<{ workspace: Workspace; created: boolean }> { const operation = workspaceCreationChain.then(async () => { - if (rejectExistingName && title !== undefined - && ctx.workspace.list().some(workspace => workspace.title === title)) { - throw new WorkspaceNameConflictError(title) - } - if (createDirectory) { - try { - await mkdir(path, { recursive: true }) - } catch (error: unknown) { - throw new WorkspaceDirectoryCreationError( - `failed to create workspace directory "${path}": ${String(error)}`, - ) - } - } const existing = await ctx.workspace.resolveByPath(path) if (existing !== undefined) return { workspace: existing, created: false } - return { workspace: await ctx.workspace.create(path, title), created: true } + return { workspace: await ctx.workspace.create(path), created: true } }) workspaceCreationChain = operation.then(() => undefined, () => undefined) return operation @@ -1931,41 +2025,51 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const { sessionId, provider, model, reasoningEffort } = request.payload const found = await agentFor(sessionId) if ('error' in found) return err(request, found.error) - try { - const resolved = await ctx.llm.resolveCallConfig({ - provider, - model, - ...reasoningEffort === undefined - ? {} - : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, - }) - const selected: ModelSelection = { - provider: resolved.provider, - model: resolved.model, - ...resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }, - } - selectionFor(found.agent).current = selected - // A switch is also how this deployment's default is chosen: the next - // session created without one of its own starts here. Sessions that - // have already logged a selection are unaffected — they derive from - // their own log (see selectionFor). + return serializeImageAdmission(found.agent, async () => { try { - await defaults.saveDefaultModelSelection?.(selected) + const resolved = await ctx.llm.resolveCallConfig({ + provider, + model, + ...reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(reasoningEffort) }, + }) + const pendingImage = [...found.agent.inbox.nextTurn, ...found.agent.inbox.nextStep] + .some(message => contentHasImage(message.content)) + if (pendingImage || messagesHaveImage(found.agent.session.deriveMessages())) { + const info = await ctx.llm.resolveModelInfo(resolved.provider, resolved.model) + if (info.inputModalities !== undefined && !info.inputModalities.includes('image')) { + return err(request, { + code: 'model-unavailable', + message: `Model "${resolved.model}" does not accept image input, but this session already contains images; select an image-capable model.`, + details: { provider, model }, + }) + } + } + const selected: ModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }, + } + selectionFor(found.agent).current = selected + try { + await defaults.saveDefaultModelSelection?.(selected) + } catch (error: unknown) { + ctx.logger.warn( + `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, + ) + } + return ok(request, { selected: { ...selected } }) } catch (error: unknown) { - ctx.logger.warn( - `api-proxy: the model switch applies to this session but was not saved as the default: ${String(error)}`, - ) + return err(request, { + code: 'model-unavailable', + message: error instanceof Error ? error.message : String(error), + details: { provider, model }, + }) } - return ok(request, { selected: { ...selected } }) - } catch (error: unknown) { - return err(request, { - code: 'model-unavailable', - message: error instanceof Error ? error.message : String(error), - details: { provider, model }, - }) - } + }) }, async rename(request) { @@ -2103,19 +2207,101 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro const agent = resolved.agent // The rpcId rides MessageSource into user/message (merge declaration in api/sessions.ts; provisional correlation). const source: MessageSource = { kind: 'user', rpcId: request.rpcId } - try { - const message: UserMessage = createUserMessage({ content, source }) - if (mode === 'steer') agent.steer(message) - else agent.followup(message) - } catch (error: unknown) { - // A synchronous throw from steer/followup means disposed or invalid input; surface as agent-busy with the reason attached. - return err(request, { code: 'agent-busy', message: 'prompt rejected', details: { reason: String(error) } }) + const hasImage = content.some(part => part.type === 'image') + const admit = async (): Promise> => { + try { + if (hasImage) { + const current = selectionFor(agent).current + const modelInfo = await ctx.llm.resolveModelInfo(current.provider, current.model) + if (modelInfo.inputModalities !== undefined && !modelInfo.inputModalities.includes('image')) { + return err(request, { + code: 'attachment-error', + message: `Model "${current.model}" does not support image input.`, + details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + }) + } + } + const durable = await durablePromptContent(ctx, content) + const message: UserMessage = createUserMessage({ content: durable, source }) + if (mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { + code: 'attachment-error', + message: error.message, + details: { reason: error.code }, + }) + } + return err(request, { + code: 'agent-busy', + message: 'prompt rejected', + details: { reason: String(error) }, + }) + } + return ok(request, { accepted: true as const }) + } + return hasImage ? serializeImageAdmission(agent, admit) : admit() + }, + + async attachment(request) { + const { sessionId, attachmentId } = request.payload + let state: SessionReadState + try { + state = await readSessionState(sessionId) + } catch (error: unknown) { + if (error instanceof SessionNotFound) { + return err(request, { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }) + } + return err(request, { + code: 'internal', + message: `attachment authorization unavailable for session "${sessionId}": ${String(error)}`, + details: {}, + }) + } + const ref = referencedImage(state.events, String(attachmentId)) + if (ref === undefined) { + return err(request, { + code: 'attachment-error', + message: 'Image is not referenced by this session.', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + try { + const stored = await ctx.attachments.readImage(ref) + return ok(request, { + attachment: stored.ref, + data: Buffer.from(stored.data).toString('base64'), + }) + } catch (error: unknown) { + if (error instanceof AttachmentError) { + return err(request, { + code: 'attachment-error', + message: error.message, + details: { reason: error.code }, + }) + } + return err(request, { + code: 'internal', + message: 'Unable to read image attachment.', + details: {}, + }) } - return ok(request, { accepted: true as const }) }, updateQueue(request) { const { sessionId, itemId, action } = request.payload + if (action.kind === 'edit' && action.content.some(block => block.type !== 'text')) { + return Promise.resolve(err(request, { + code: 'attachment-error', + message: 'queue edits accept text content only', + details: { reason: 'QUEUE_EDIT_NON_TEXT' }, + })) + } const agent = ctx.agents.get(sessionId) if (agent !== undefined && hasSubagentOwner(agent.session, agent)) { return Promise.resolve(err(request, subagentOwnershipError(sessionId))) @@ -2335,54 +2521,12 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro })) }, - // Exactly one of path/name arrives (schema refine). Existing-folder - // adoption reuses its canonical path; create-by-name rejects a name - // already present in the registry. - // TODO: the create-by-name branch lost its last product consumer when - // the Web picker collapsed onto the directory flow - // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md). - // Delete it with the wire schema's `name` member, this - // `defaults.workspaceRoot`, the client contract that carried the name - // (`WorkspaceCreateInput`, `WorkspacesService.create`'s `{ name }` arm, - // `intentName`'s name branch, the manager's "name under workspaceRoot" - // contract), and the `dsh web --workspace-root` flag plus its apps/cli - // README lines, which exist only to feed it. async create(request) { - const { payload } = request - let path: string - if (payload.name !== undefined) { - const name = payload.name.trim() - if (name === '' || name === '.' || name === '..' || /[/\\]/.test(name)) { - return err(request, { - code: 'workspace-invalid-path', - message: `workspace name must be one non-empty path segment, got "${payload.name}"`, - details: { path: payload.name }, - }) - } - path = join(defaults.workspaceRoot, name) - } else { - path = payload.path as string - } + const { path } = request.payload try { - const name = payload.name?.trim() - const { workspace, created } = await ensureWorkspace( - path, - name, - name !== undefined, - name !== undefined, - ) + const { workspace, created } = await ensureWorkspace(path) return ok(request, { workspace: workspaceView(workspace), created }) } catch (error: unknown) { - if (error instanceof WorkspaceNameConflictError) { - return err(request, { - code: 'workspace-name-conflict', - message: error.message, - details: { name: error.workspaceName }, - }) - } - if (error instanceof WorkspaceDirectoryCreationError) { - return err(request, { code: 'internal', message: error.message, details: {} }) - } // The registry rejects a path that does not resolve to an existing // directory (realpath ENOENT / not-a-directory) — the business // error of the typed-path flow, surfaced as a validation failure. diff --git a/packages/host/apiproxy/src/api/host.schema.ts b/packages/host/apiproxy/src/api/host.schema.ts index 43031288fc..15084b5d07 100644 --- a/packages/host/apiproxy/src/api/host.schema.ts +++ b/packages/host/apiproxy/src/api/host.schema.ts @@ -17,8 +17,6 @@ export const hostDescribeValueSchema = z.object({ provider: z.string().optional(), model: z.string().optional(), attachedSessions: z.number().int().nonnegative(), - // Open string, not a literal union: unknown kinds must survive the wire so - // a merge-added capability can advertise (the client hides the affordance). }) satisfies z.ZodType>> /** host.pickDirectory request payload (empty object literal). */ diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 83ca08c0a6..e8eb3f3272 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -39,7 +39,8 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, QueueAction, SessionModels, SessionProjectionsBlock, SessionSearchItem, + ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock, + SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' diff --git a/packages/host/apiproxy/src/api/rpc-map.ts b/packages/host/apiproxy/src/api/rpc-map.ts index f81f19ec19..ca7231e774 100644 --- a/packages/host/apiproxy/src/api/rpc-map.ts +++ b/packages/host/apiproxy/src/api/rpc-map.ts @@ -32,6 +32,7 @@ export interface RpcMethodMap { 'session.rename': SessionsApi['rename'] 'session.fork': SessionsApi['fork'] 'session.prompt': SessionsApi['prompt'] + 'session.attachment': SessionsApi['attachment'] 'session.updateQueue': SessionsApi['updateQueue'] 'session.cancel': SessionsApi['cancel'] 'subagent.list': SubagentsApi['list'] diff --git a/packages/host/apiproxy/src/api/rpc.schema.ts b/packages/host/apiproxy/src/api/rpc.schema.ts index 5a758ee56f..145b0500cf 100644 --- a/packages/host/apiproxy/src/api/rpc.schema.ts +++ b/packages/host/apiproxy/src/api/rpc.schema.ts @@ -52,6 +52,7 @@ export const rpcErrorSchema: z.ZodType = z.discriminatedUnion('code', z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }), z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }), z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }), + z.object({ code: z.literal('attachment-error'), message: z.string(), details: z.object({ reason: z.string() }) }), z.object({ code: z.literal('queue-item-not-found'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('steer-unavailable'), message: z.string(), details: z.object({ itemId: z.string() }) }), z.object({ code: z.literal('command-error'), message: z.string(), details: z.object({}) }), diff --git a/packages/host/apiproxy/src/api/rpc.ts b/packages/host/apiproxy/src/api/rpc.ts index f799074c88..a8c7101de7 100644 --- a/packages/host/apiproxy/src/api/rpc.ts +++ b/packages/host/apiproxy/src/api/rpc.ts @@ -50,6 +50,7 @@ export interface RpcErrorDetailsMap { 'agent-preset-not-found': { agentPreset: string; available: string[] } 'agent-preset-invalid': { agentPreset: string; reason: string } 'agent-busy': { reason: string } + 'attachment-error': { reason: string } 'queue-item-not-found': { itemId: MessageId } 'steer-unavailable': { itemId: MessageId } /** A known slash command reported a usage/state error; the message is the command's own text. */ diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index a33ab20f8f..e0ce444acf 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -15,6 +15,7 @@ import type { ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary, } from './sessions.ts' import type { ToolEventView } from './events.ts' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { WorkspaceId } from './workspace.ts' import { SESSION_SEARCH_RESULT_LIMIT, @@ -249,11 +250,25 @@ export const sessionSelectModelValueSchema = z.object({ /** ContentBlock passthrough: core is merge-extensible — the type discriminant envelope is strict, the rest stays wide. */ export const contentBlockSchema = z.looseObject({ type: z.string() }) +/** Raster image media types accepted by the version-one browser wire. */ +export const imageMediaTypeSchema = z.union([ + z.literal('image/png'), + z.literal('image/jpeg'), + z.literal('image/webp'), + z.literal('image/gif'), +]) + +/** Prompt wire content is intentionally narrower than merge-extensible durable core content. */ +export const promptContentPartSchema = z.discriminatedUnion('type', [ + z.object({ type: z.literal('text'), text: z.string() }), + z.object({ type: z.literal('image'), mediaType: imageMediaTypeSchema, data: z.string(), name: z.string().optional() }), +]) + /** session.prompt request payload. */ export const sessionPromptRequestSchema = z.object({ sessionId: sessionIdSchema, mode: z.union([z.literal('queue'), z.literal('steer')]), - content: z.array(contentBlockSchema), + content: z.array(promptContentPartSchema), }) as unknown as z.ZodType> /** session.prompt response value (the command slot appears only when the prompt dispatched a slash command). */ @@ -265,6 +280,31 @@ export const sessionPromptValueSchema = z.object({ }).optional(), }) satisfies z.ZodType>> +/** Opaque attachment id after string-shape validation. */ +export const attachmentIdSchema = z.string().min(1) as unknown as z.ZodType + +/** Durable image reference returned from the authenticated session lookup. */ +export const imageAttachmentRefSchema = z.object({ + attachmentId: attachmentIdSchema, + mediaType: imageMediaTypeSchema, + bytes: z.number().int().positive(), + width: z.number().int().positive(), + height: z.number().int().positive(), + name: z.string().optional(), +}) as unknown as z.ZodType + +/** session.attachment request payload. */ +export const sessionAttachmentRequestSchema = z.object({ + sessionId: sessionIdSchema, + attachmentId: attachmentIdSchema, +}) satisfies z.ZodType>> + +/** session.attachment response value. */ +export const sessionAttachmentValueSchema = z.object({ + attachment: imageAttachmentRefSchema, + data: z.string(), +}) satisfies z.ZodType>> + /** session.updateQueue request payload. */ export const sessionUpdateQueueRequestSchema = z.object({ sessionId: sessionIdSchema, diff --git a/packages/host/apiproxy/src/api/sessions.ts b/packages/host/apiproxy/src/api/sessions.ts index f2a34e62c8..d1f0317e8f 100644 --- a/packages/host/apiproxy/src/api/sessions.ts +++ b/packages/host/apiproxy/src/api/sessions.ts @@ -5,6 +5,7 @@ */ import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' // The pure-type outlet: api/ is browser-importable, and the package root's @@ -53,6 +54,11 @@ export interface SessionProjectionsBlock { values: Partial } +/** Browser-submitted prompt content; the host promotes image bytes to durable references. */ +export type PromptContentPart = + | { type: 'text'; text: string } + | { type: 'image'; mediaType: ImageMediaType; data: string; name?: string } + /** Complete model selection for one session. */ export interface ModelSelection { /** Registered provider route. */ @@ -302,10 +308,14 @@ export interface SessionsApi { fork(request: RpcRequest<{ sessionId: SessionId; atSeq?: number }>): Promise> - /** Sends a message to an ordinary session Agent. Session-backed subagents reject with `agent-busy` and use `subagent.prompt`. */ - prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: ContentBlock[] }>): + /** Sends text and temporary image bytes after durable host admission. Session-backed subagents reject with `agent-busy`. */ + prompt(request: RpcRequest<{ sessionId: SessionId; mode: 'queue' | 'steer'; content: PromptContentPart[] }>): Promise> + /** Reads one durable image after proving that this session's log references its id. */ + attachment(request: RpcRequest<{ sessionId: SessionId; attachmentId: AttachmentIdType }>): + Promise> + /** * Edits, removes, or strictly steers one pending queued occurrence on an ordinary session. * Session-backed subagents reject with `agent-busy`. diff --git a/packages/host/apiproxy/src/api/workspace.schema.ts b/packages/host/apiproxy/src/api/workspace.schema.ts index 20b3038301..5ad5a0b96b 100644 --- a/packages/host/apiproxy/src/api/workspace.schema.ts +++ b/packages/host/apiproxy/src/api/workspace.schema.ts @@ -31,14 +31,10 @@ export const workspaceListValueSchema = z.object({ archivedSessionIds: z.array(sessionIdSchema), }) satisfies z.ZodType>> -/** workspace.create request payload: exactly one of path/name (the contract's create spellings). */ +/** workspace.create request payload: the existing directory to adopt. */ export const workspaceCreateRequestSchema = z.object({ - path: z.string().optional(), - name: z.string().optional(), -}).refine( - payload => (payload.path === undefined) !== (payload.name === undefined), - { message: 'workspace.create requires exactly one of path / name' }, -) satisfies z.ZodType>> + path: z.string(), +}) satisfies z.ZodType>> /** workspace.create response value. */ export const workspaceCreateValueSchema = z.object({ diff --git a/packages/host/apiproxy/src/api/workspace.ts b/packages/host/apiproxy/src/api/workspace.ts index d5307e27a8..64feb27f80 100644 --- a/packages/host/apiproxy/src/api/workspace.ts +++ b/packages/host/apiproxy/src/api/workspace.ts @@ -46,19 +46,14 @@ export interface WorkspaceApi { list(request: RpcRequest<{}>): Promise> /** - * Creates (or idempotently resolves) a workspace. Exactly one of `path` / - * `name` (schema-enforced): `path` registers an EXISTING directory (no - * mkdir — a missing or non-directory path fails with `workspace-invalid-path`); - * `name` is a single path segment the host mkdirs under its default project - * root before registering. Either spelling resolving to a directory already - * owned by a workspace returns that workspace (`created: false`) for the - * existing-folder spelling. Create-by-name rejects an existing title with - * `workspace-name-conflict`; path adoption allows distinct canonical paths - * whose basenames produce the same display title. - * A new name-created workspace uses `name` as both directory name and title; - * a path-created workspace uses the registry's basename title default. + * Creates (or idempotently resolves) a workspace over an EXISTING directory + * (no mkdir — a missing or non-directory path fails with + * `workspace-invalid-path`). A path resolving to a directory already owned + * by a workspace returns that workspace (`created: false`). Adoption allows + * distinct canonical paths whose basenames produce the same display title; + * the registry's basename title default names the new workspace. */ - create(request: RpcRequest<{ path?: string; name?: string }>): + create(request: RpcRequest<{ path: string }>): Promise> /** diff --git a/packages/host/apiproxy/src/fetch/client.ts b/packages/host/apiproxy/src/fetch/client.ts index bbb8b3872a..6060875a6f 100644 --- a/packages/host/apiproxy/src/fetch/client.ts +++ b/packages/host/apiproxy/src/fetch/client.ts @@ -19,6 +19,7 @@ import { } from '../api/host.schema.ts' import { sessionCancelValueSchema, + sessionAttachmentValueSchema, sessionCreateValueSchema, sessionForkValueSchema, sessionHistoryValueSchema, @@ -94,6 +95,7 @@ export interface IApiClient { rename(payload: RequestPayload<'session.rename'>, signal?: AbortSignal): Promise>> fork(payload: RequestPayload<'session.fork'>, signal?: AbortSignal): Promise>> prompt(payload: RequestPayload<'session.prompt'>, signal?: AbortSignal): Promise>> + attachment(payload: RequestPayload<'session.attachment'>, signal?: AbortSignal): Promise>> updateQueue(payload: RequestPayload<'session.updateQueue'>, signal?: AbortSignal): Promise>> cancel(payload: RequestPayload<'session.cancel'>, signal?: AbortSignal): Promise>> } @@ -180,6 +182,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType this.callUnary('session.rename', payload, signal), fork: (payload, signal) => this.callUnary('session.fork', payload, signal), prompt: (payload, signal) => this.callUnary('session.prompt', payload, signal), + attachment: (payload, signal) => this.callUnary('session.attachment', payload, signal), updateQueue: (payload, signal) => this.callUnary('session.updateQueue', payload, signal), cancel: (payload, signal) => this.callUnary('session.cancel', payload, signal), } diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index 7474f371f1..f62a3584e7 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -16,6 +16,7 @@ import type { Wire } from '../api/rpc.schema.ts' import { clientRequestSchema, clientResponseSchema } from '../api/rpc.schema.ts' import { sessionCancelRequestSchema, + sessionAttachmentRequestSchema, sessionCreateRequestSchema, sessionForkRequestSchema, sessionHistoryRequestSchema, @@ -95,6 +96,7 @@ const UNARY_ROUTES: UnaryRoutes = { 'session.rename': { schema: sessionRenameRequestSchema, invoke: (api, r) => api.sessions.rename(r) }, 'session.fork': { schema: sessionForkRequestSchema, invoke: (api, r) => api.sessions.fork(r) }, 'session.prompt': { schema: sessionPromptRequestSchema, invoke: (api, r) => api.sessions.prompt(r) }, + 'session.attachment': { schema: sessionAttachmentRequestSchema, invoke: (api, r) => api.sessions.attachment(r) }, 'session.updateQueue': { schema: sessionUpdateQueueRequestSchema, invoke: (api, r) => api.sessions.updateQueue(r) }, 'session.cancel': { schema: sessionCancelRequestSchema, invoke: (api, r) => api.sessions.cancel(r) }, 'subagent.list': { schema: subagentListRequestSchema, invoke: (api, r, signal) => api.subagents.list(r, signal) }, diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 3e5157ca90..43ce9e2df4 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -12,7 +12,6 @@ * service; sessions that have already logged a selection remain unchanged. */ -import { resolve } from 'node:path' import { Context, Service } from 'cordis' import z from 'schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' @@ -34,10 +33,8 @@ declare module 'cordis' { } } -/** Gateway plugin config: the Host-only Workspace creation root. */ +/** Gateway plugin config for native Host integration. */ export interface Config { - /** Parent directory for name-created Workspaces; defaults to the Host cwd. */ - workspaceRoot?: string /** * Whether this deployment can hand paths to a native desktop opener — * the `hasDocument` capability the agent-preset roster reports. Absent, @@ -51,16 +48,15 @@ export interface Config { /** * The API gateway service: implements the ApiProxy contract over the composed * host context and provides it as `ctx.apiProxy`. The Host cwd is the default - * project directory and the fallback parent for name-created Workspaces. + * project directory. */ export class ApiProxyService extends Service implements ApiProxy { static inject = [ - 'agentDefaultModel', 'agents', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', + 'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'subagents', 'sessionQuery', 'tools', 'userInteraction', 'workspace', ] static Config: z = z.object({ - workspaceRoot: z.string(), nativeOpen: z.boolean(), }) @@ -80,12 +76,10 @@ export class ApiProxyService extends Service implements ApiProxy { constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') - const cwd = process.cwd() const api = createApiProxy(ctx, { defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(), saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection), - cwd, - workspaceRoot: resolve(config.workspaceRoot ?? cwd), + cwd: process.cwd(), ...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean }, }) this.sessions = api.sessions diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 444cd5490e..eb707c01f6 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -137,7 +137,6 @@ async function harness( const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), cwd, - workspaceRoot: cwd, ...options.defaults, }) return { api, ctx, cwd } diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 2272c7c61d..1cee4cd4e0 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) await ctx.plugin(ApprovalService) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) return { ctx, api } } @@ -217,7 +217,7 @@ describe('approval pending registry', () => { await ctx.plugin(ApprovalService) let api!: ApiProxy const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => { - api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) }, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] })) await fiber.await() const abort = new AbortController() diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index ed2eaeefc5..e01282043b 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio await ctx.plugin(AgentRegistry) return { ctx, - api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), attach: (session) => { ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) }, diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 37e0e14169..78c748861a 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => { return undefined }, }) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await api.sessions.list(request({})) expect(response.result.ok).toBe(true) @@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => { await ctx.plugin(SessionStore) await ctx.plugin(UserInteractionService) await ctx.plugin(AgentRegistry) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) // Old work, resumed just now: the log tail would report the pickup. const worked = 1_000_000 @@ -150,7 +150,7 @@ describe('cold history recovery view', () => { inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), locate: () => undefined, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 })) if (!history.result.ok) throw new Error('history failed') @@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => { }) const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => { const resume = vi.spyOn(ctx.agents, 'resume') const defaultAgentLookup = ctx.typert.lookups.get('agent') const defaultSessionLookup = ctx.typert.lookups.get('session') - createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) await vi.waitFor(() => { expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) @@ -312,7 +312,7 @@ describe('subagent ownership fence', () => { locate: () => undefined, } as never) const resume = vi.spyOn(ctx.agents, 'resume') - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const history = await api.sessions.history(request({ sessionId })) expect(history.result.ok).toBe(true) @@ -371,7 +371,7 @@ describe('subagent ownership fence', () => { // answering `agent-busy`. const resume = vi.spyOn(ctx.agents, 'resume') .mockRejectedValue(new Error('registry unavailable in this bench')) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const prompt = await api.sessions.prompt(request({ sessionId, @@ -412,7 +412,7 @@ describe('subagent ownership fence', () => { }) const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent ctx.agents.enter(startingChild, parent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const stopped = await api.sessions.cancel(request({ sessionId: originChild.id })) expect(stopped.result.ok).toBe(false) @@ -458,7 +458,7 @@ describe('subagent ownership fence', () => { const followup = vi.fn() const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent ctx.agents.register(agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await api.sessions.prompt(request({ sessionId: agent.id, @@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => { await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(UserInteractionService) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const listed = await api.sessions.list(request({})) expect(listed.result.ok).toBe(true) @@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => { list: () => Promise.resolve([]), inspect, } as never) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const response = await api.sessions.history(request({ sessionId: sid('session-missing') })) expect(response.result.ok).toBe(false) @@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => { followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, } as unknown as Agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) for (const mode of ['queue', 'steer'] as const) { const response = await api.sessions.prompt(request({ @@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => { ctx.agents.register(child) throw new Error('session id already published') }) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const models = await api.sessions.models(request({ sessionId })) expect(models.result.ok).toBe(false) diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index b44701fca0..5f0af7cc5c 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts' import { RpcId } from '../src/api/rpc.ts' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } function request

(payload: P): RpcRequest

{ return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload } diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index fee9c71b6f..15b0092107 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -25,7 +25,7 @@ import { RpcId } from '../src/api/rpc.ts' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' import { createApiProxy } from '../src/api-proxy.ts' -const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } let nextRpc = 1 function request

(payload: P): RpcRequest

{ diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index bc1f0a14df..d1bf611fbb 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -84,7 +84,6 @@ function liveAgent( const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) describe('sessions.fork', () => { diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index 83d0fa3916..bdd21128b4 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -5,7 +5,7 @@ * boundary for a running selection change. */ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -13,6 +13,7 @@ import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmCallConfig, LlmModelInfo, LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, + UserMessage, } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -108,7 +109,8 @@ async function harness(logged?: { session, status: 'running', ctx, - } as Agent + inbox: { nextTurn: [], nextStep: [] }, + } as unknown as Agent ctx.agents.register(agent) return { ctx, agent, sessionId: session.id } } @@ -118,14 +120,161 @@ function expectValue(response: { result: { ok: true; value: T } | { ok: false return response.result.value } +function registerTextOnly(ctx: Context): void { + ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + }('Text Only', [])) +} + describe('Web session model selection', () => { + it('validates an ordered image batch before persisting any member', async () => { + const { ctx, agent, sessionId } = await harness() + const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve()) + const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({ + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + })) + ctx.provide('attachments', { + imageLimits: { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 4, + maxImagePixels: 4, + mediaTypes: ['image/png'], + }, + validateImage, + saveImage, + } as never) + const followup = vi.fn() + Object.assign(agent, { followup }) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + + const result = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: [ + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }, + { type: 'text' as const, text: 'compare' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' }, + ], + })) + expect(result.result.ok).toBe(true) + expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { + type: 'image', + attachment: { + attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png', + }, + }, + { type: 'text', text: 'compare' }, + { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + + const denied = await api.sessions.prompt(request({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 3 }, () => ({ + type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', + })), + })) + expect(denied.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, + }) + expect(saveImage).toHaveBeenCalledTimes(2) + await ctx.fiber.dispose() + }) + + it('refuses a text-only selection while durable or pending image content remains visible', async () => { + const { ctx, agent, sessionId } = await harness() + registerTextOnly(ctx) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + const image = { + type: 'image' as const, + attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, + } + agent.session.append('user/message', { + id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image], + } as never, { surfaceOp: 'append' }) + expect((await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).result).toMatchObject({ ok: false, error: { code: 'model-unavailable' } }) + + agent.session.append('user/message', { + id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, + content: [{ type: 'text', text: 'image summarized' }], + } as never, { + surfaceOp: { op: 'replace', start: 0, end: agent.session.events.length - 1 }, + sourceEventSeqs: agent.session.events.map(event => event.seq), + }) + ;(agent.inbox.nextTurn as UserMessage[]).push({ + id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image], + } as never) + expect((await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).result.ok).toBe(false) + ;(agent.inbox.nextTurn as UserMessage[]).length = 0 + expect(expectValue(await api.sessions.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + await ctx.fiber.dispose() + }) + + it('authorizes attachment bytes only when the session event stream references the id', async () => { + const { ctx, agent, sessionId } = await harness() + const ref = { + attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, + } + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.provide('attachments', { readImage } as never) + const api = createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + agent.session.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [{ + id: 'queued-image', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: ref }], + }], + } as never) + + const allowed = await api.sessions.attachment(request({ + sessionId, attachmentId: 'att-authorized' as never, + })) + expect(allowed.result).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) + const denied = await api.sessions.attachment(request({ + sessionId, attachmentId: 'att-other' as never, + })) + expect(denied.result).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) + expect(readImage).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { const { ctx, sessionId } = await harness({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: ReasoningEffortId('max'), }) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) expect(catalog.current).toEqual({ @@ -160,7 +309,7 @@ describe('Web session model selection', () => { it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { const { ctx, agent, sessionId } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } const signal = new AbortController().signal @@ -232,7 +381,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', - workspaceRoot: '/tmp', }) expect(expectValue(await api.sessions.models(request({ sessionId }))).current) @@ -257,7 +405,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => stored, cwd: '/tmp', - workspaceRoot: '/tmp', }) stored = { provider: 'duplicate', model: 'same' } @@ -277,7 +424,6 @@ describe('Web session model selection', () => { return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve() }, cwd: '/tmp', - workspaceRoot: '/tmp', }) expectValue(await api.sessions.selectModel(request({ @@ -308,7 +454,6 @@ describe('Web session model selection', () => { const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) // The client disabling its input is an affordance; this method stays @@ -341,7 +486,6 @@ describe('Web session model selection', () => { // names the route the user last picked, and nothing serves it. defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), cwd: '/tmp', - workspaceRoot: '/tmp', }) const catalog = expectValue(await api.sessions.models(request({ sessionId }))) diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index ddc558fefd..45335e131b 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void { } } -const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('session.history projections block', () => { it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index 00f7fdfffe..2ca7a31d12 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> { await ctx.plugin(UserInteractionService) return { ctx, - api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }), + api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), } } diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 9d5ddb09fd..346ed9f313 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session { return session } -const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) describe('sessions.rename', () => { it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 8b2e030509..7637399f50 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const sid = (value: string): SessionId => value as SessionId -const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' } +const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } function request(query: string): RpcRequest<{ query: string }> { return { rpcId: RpcId(`search-${query}`), payload: { query } } diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index 5e46e4b212..eb39bb6791 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -95,7 +95,7 @@ function bench(options: { ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} }) ctx.provide('userInteraction', { registerProvider: () => () => {} }) const api = createApiProxy(ctx, { - defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp', + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', }) return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent } } diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 8b82a99d27..86fb593f4b 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num describe('mux live view computation', () => { it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal) const collected = collect(stream, 9, abort) @@ -170,7 +170,7 @@ describe('mux live view computation', () => { it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() // history resolves the agent first; a live structural stub is enough (only // .session is read on this path). @@ -238,7 +238,7 @@ describe('mux live view computation', () => { it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) session.append('turn/start', { turn: 1 }) @@ -287,7 +287,7 @@ describe('mux live view computation', () => { it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal) @@ -308,7 +308,7 @@ describe('mux live view computation', () => { it('pairs a result after turn/end via the in-memory backscan fallback', async () => { const { ctx } = await harness() - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) const abort = new AbortController() const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal) const collected = collect(stream, 4, abort) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index ced130d7f7..ddb53cf02f 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent { /** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */ async function harness( - workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), + root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, ) { @@ -101,11 +101,17 @@ async function harness( ctx.provide('directoryPicker', { capability: () => picker } as never) const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), - cwd: workspaceRoot, - workspaceRoot, + cwd: root, ...extras.openPath === undefined ? {} : { openPath: extras.openPath }, }) - return { api, ctx, storageDomain, workspaceRoot } + return { api, ctx, storageDomain, root } +} + +/** Stage one directory under the harness root for path adoption. */ +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path) + return path } describe('host.pickDirectory', () => { @@ -243,31 +249,25 @@ describe('host.openPath', () => { }) describe('workspace.create', () => { - it('serializes concurrent names and rejects the duplicate', async () => { - const { api, workspaceRoot } = await harness() + it('serializes concurrent creates of one path into a single registration', async () => { + const { api, root } = await harness() + const target = stageDir(root, 'alpha') const responses = await Promise.all([ - api.workspace.create(request({ name: 'alpha' })), - api.workspace.create(request({ name: 'alpha' })), + api.workspace.create(request({ path: target })), + api.workspace.create(request({ path: target })), ]) - const created = responses.find(response => response.result.ok) - const duplicate = responses.find(response => !response.result.ok) + const values = responses.map(response => expectOk(response)) + const created = values.find(value => value.created) + const resolved = values.find(value => !value.created) - expect(created).toBeDefined() - expect(expectOk(created!)).toMatchObject({ - created: true, - workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' }, - }) - expect(duplicate?.result).toMatchObject({ - ok: false, - error: { code: 'workspace-name-conflict', details: { name: 'alpha' } }, - }) - expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true) + expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1) }) - it('adopts only existing directories and rejects unsafe names', async () => { - const { api, workspaceRoot } = await harness() - const existing = join(workspaceRoot, 'existing') - mkdirSync(existing) + it('adopts only existing directories', async () => { + const { api, root } = await harness() + const existing = stageDir(root, 'existing') const first = expectOk(await api.workspace.create(request({ path: existing }))) const repeated = expectOk(await api.workspace.create(request({ path: existing }))) expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } }) @@ -280,21 +280,16 @@ describe('workspace.create', () => { const reopened = expectOk(await api.workspace.create(request({ path: existing }))) expect(reopened.workspace.title).toBe('renamed-existing') - const missing = join(workspaceRoot, 'missing') + const missing = join(root, 'missing') const missingResult = await api.workspace.create(request({ path: missing })) expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) expect(existsSync(missing)).toBe(false) - - for (const name of ['', '.', '..', 'a/b', 'a\\b']) { - const invalid = await api.workspace.create(request({ name })) - expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } }) - } }) it('adopts different paths that derive the same Workspace title', async () => { - const { api, workspaceRoot } = await harness() - const first = join(workspaceRoot, 'one', 'project') - const second = join(workspaceRoot, 'two', 'project') + const { api, root } = await harness() + const first = join(root, 'one', 'project') + const second = join(root, 'two', 'project') mkdirSync(first, { recursive: true }) mkdirSync(second, { recursive: true }) const firstResult = expectOk(await api.workspace.create(request({ path: first }))) @@ -315,8 +310,8 @@ describe('workspace.create', () => { describe('session creation and Workspace membership', () => { it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const sessionId = SessionId('session-workspace-preallocated') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => { }) it('retains a published session when attachment fails and repairs it on retry', async () => { - const { api, ctx } = await harness() - const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const { api, ctx, root } = await harness() + const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace const workspace = ctx.workspace.list()[0] if (workspace === undefined) throw new Error('workspace missing from registry') vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure')) @@ -393,7 +388,7 @@ describe('Host Workspace increments', () => { }) it('streams committed Workspace and Session increments after empty baselines', async () => { - const { api } = await harness() + const { api, root } = await harness() expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) expect(expectOk(await api.sessions.list(request({}))).items).toEqual([]) @@ -401,7 +396,7 @@ describe('Host Workspace increments', () => { const stream: AsyncIterator> = api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const workspaceIncrement = nextHostFrame(stream) - const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace expect(await workspaceIncrement).toMatchObject({ payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } }, }) @@ -429,7 +424,7 @@ describe('Host Workspace increments', () => { }) it('does not publish a Workspace whose registry-order commit fails', async () => { - const { api, storageDomain } = await harness() + const { api, storageDomain, root } = await harness() const domain = storageDomain.get('workspace') if (domain === undefined) throw new Error('workspace domain is not open') vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure')) @@ -438,7 +433,7 @@ describe('Host Workspace increments', () => { api.events.host(request({}), abort.signal)[Symbol.asyncIterator]() const next = stream.next() - const failed = await api.workspace.create(request({ name: 'ghost' })) + const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') })) expect(failed.result.ok).toBe(false) expect(expectOk(await api.workspace.list(request({}))).items).toEqual([]) abort.abort() @@ -446,8 +441,8 @@ describe('Host Workspace increments', () => { }) it('deletes the registration, keeps its session and folder, and streams one removal', async () => { - const { api, ctx } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace + const { api, ctx, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace const sessionId = SessionId('session-kept-after-workspace-delete') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) @@ -479,8 +474,8 @@ describe('Host Workspace increments', () => { }) it('archives a session into the global set, keeps its accounting, and streams the set once', async () => { - const { api } = await harness() - const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace + const { api, root } = await harness() + const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace const sessionId = SessionId('session-to-archive') expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId }))) expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([]) diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 72654e868b..2b9249b7f0 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -56,6 +56,10 @@ function scriptedApi(overrides: { rename: r => ok(r, { title: 'renamed', seq: 0 }), fork: r => ok(r, { sessionId: sid('s-fork') }), prompt: r => ok(r, { accepted: true as const }), + attachment: r => ok(r, { + attachment: { attachmentId: 'a' as never, mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: 'AA==', + }), updateQueue: r => ok(r, { accepted: true as const }), cancel: r => ok(r, { accepted: true as const }), ...overrides.sessions, @@ -428,8 +432,8 @@ describe('workspace domain round trip', () => { expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } }) }) - it('rejects a create payload violating the exactly-one refine at the handler', async () => { - const response = await client(scriptedApi()).workspace.create({}) + it('rejects a pathless create payload at the handler schema', async () => { + const response = await client(scriptedApi()).workspace.create({} as never) expect(response.result.ok).toBe(false) if (!response.result.ok) expect(response.result.error.code).toBe('bad-request') }) diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 9160a61e8e..63c40414d8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -97,6 +97,12 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async prompt(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, + async attachment(request) { + return { + rpcId: request.rpcId, + result: { ok: true, value: { attachment: { attachmentId: 'a' as never, mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, data: 'AA==' } }, + } + }, async updateQueue(request) { return { rpcId: request.rpcId, result: { ok: true, value: { accepted: true as const } } } }, @@ -357,6 +363,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => { const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' }) expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } }) expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true) + expect((await c.sessions.attachment({ sessionId: 's' as never, attachmentId: 'a' as never })).result.ok).toBe(true) expect((await c.sessions.updateQueue({ sessionId: 's' as never, itemId: 'item-1' as never, diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index f398305fa7..3be4bf5715 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -330,11 +330,11 @@ describe('workspace domain schemas', () => { expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow() }) - it('create requires exactly one of path/name (both refine arms)', () => { + it('create requires a path', () => { expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p') - expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n') - expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/) - expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/) + expect(() => workspaceCreateRequestSchema.parse({})).toThrow() + // The retired create-by-name spelling stays a clean schema rejection. + expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow() expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false) }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 624951059e..42b60ce906 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../llm/llm" }, diff --git a/packages/llm/llm-deepseek/src/adapter.ts b/packages/llm/llm-deepseek/src/adapter.ts index 4c29bfcf11..aa4d73593f 100644 --- a/packages/llm/llm-deepseek/src/adapter.ts +++ b/packages/llm/llm-deepseek/src/adapter.ts @@ -107,6 +107,7 @@ function modelInfo(provider: string, model: DeepSeekCatalogModel): LlmModelInfo id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, + inputModalities: ['text'], } } @@ -178,8 +179,12 @@ export class DeepSeekAdapter extends LlmAdapter { const contextWindow = configured?.contextWindow ?? connection.defaultContextWindow return Promise.resolve({ + // The chat-completions wire route is text-only regardless of catalog + // membership, so the uncatalogued fallback declares the same negative + // capability — "unknown" here would let the host accept and persist + // images the serializer must then reject. ...configured === undefined - ? { provider, id: model, name: model } + ? { provider, id: model, name: model, inputModalities: ['text' as const] } : modelInfo(provider, configured), context: { contextWindow }, defaultMaxTokens: configured?.maxTokens ?? connection.maxTokens, diff --git a/packages/llm/llm-deepseek/src/serialize.ts b/packages/llm/llm-deepseek/src/serialize.ts index bb6443425e..34fa214bb9 100644 --- a/packages/llm/llm-deepseek/src/serialize.ts +++ b/packages/llm/llm-deepseek/src/serialize.ts @@ -2,11 +2,12 @@ * Serialize harness messages into DeepSeek chat completions. User text is joined; assistant text * becomes `content`, tool calls become `tool_calls`, and tool results become separate tool messages. * Assistant reasoning is replayed as `reasoning_content` only on tool-call turns, as required by - * thinking-mode passback. Unknown declaration-merged block types are skipped rather than rejected. + * thinking-mode passback. Core image blocks are rejected explicitly because this wire route is text-only; + * unknown declaration-merged block types retain the adapter's documented extension fallback. * @module dsh-llm-deepseek/serialize */ -import { LlmError } from '@deepseek-ai/dsh-llm' +import { contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import type { WireMessage, WireRequest, WireTool } from './types.ts' @@ -59,6 +60,13 @@ function flattenText(blocks: ContentBlock[]): string { .join('') } +/** Reject core image content before any text-flattening path can silently erase it. */ +function assertTextOnly(blocks: readonly ContentBlock[]): void { + if (contentHasImage(blocks)) { + throw new LlmError('The DeepSeek chat-completions adapter does not support image content.', 'UNSUPPORTED_CONTENT') + } +} + /** Serialize one assistant message (text + reasoning + tool calls). */ function serializeAssistant(message: Message): WireMessage { const text = flattenText(message.content) @@ -104,6 +112,7 @@ function serializeAssistant(message: Message): WireMessage { export function serializeMessages(messages: Message[]): WireMessage[] { const wire: WireMessage[] = [] for (const message of messages) { + assertTextOnly(message.content) if (message.role === 'system') { wire.push({ role: 'system', content: flattenText(message.content) }) continue diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 99e2ce6a8c..64e2bb1e7d 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -642,8 +642,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmDeepSeek, { baseURL: 'http://127.0.0.1:1' }) expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek-official', name: 'DeepSeek' }]) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'deepseek-v4-flash')) .resolves.toMatchObject({ @@ -737,8 +737,8 @@ describe('plugin registration and config', () => { await ctx.plugin(LlmService) LlmDeepSeek.apply(ctx, { baseURL: 'http://127.0.0.1:1' }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' }, - { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' }, + { provider: 'deepseek-official', id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro', inputModalities: ['text'] }, ]) }) @@ -758,8 +758,8 @@ describe('plugin registration and config', () => { ], }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast' }, - { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' }, + { provider: 'deepseek-official', id: 'private-fast', name: 'private-fast', inputModalities: ['text'] }, + { provider: 'deepseek-official', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget', inputModalities: ['text'] }, ]) await expect(ctx.llm.resolveModelInfo('deepseek-official', 'private-fast')) .resolves.toMatchObject({ context: { contextWindow: 32_000 } }) diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 99e57d10c4..769264b11b 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -117,7 +117,7 @@ describe('request-level dynamic configuration', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings' }, + { provider: 'deepseek-official', id: 'settings-model', name: 'From Settings', inputModalities: ['text'] }, ]) }) @@ -156,7 +156,7 @@ describe('request-level dynamic configuration', () => { await expect(ctx.llm.listModels('deepseek-official')).resolves.toHaveLength(2) await ctx.settings.update(NS, { models: [{ id: 'recovered' }] }) await expect(ctx.llm.listModels('deepseek-official')).resolves.toEqual([ - { provider: 'deepseek-official', id: 'recovered', name: 'recovered' }, + { provider: 'deepseek-official', id: 'recovered', name: 'recovered', inputModalities: ['text'] }, ]) }) diff --git a/packages/llm/llm-deepseek/tests/serialize.spec.ts b/packages/llm/llm-deepseek/tests/serialize.spec.ts index 167feef89a..e7e95703fb 100644 --- a/packages/llm/llm-deepseek/tests/serialize.spec.ts +++ b/packages/llm/llm-deepseek/tests/serialize.spec.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' -import { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { createUserMessage, CallId, ReasoningEffortId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { serializeMessages, serializeRequest } from '../src/serialize.ts' @@ -132,6 +133,19 @@ describe('serializeMessages', () => { expect(wire).toEqual([{ role: 'user', content: 'see chart' }]) }) + it('rejects image blocks instead of silently flattening them away', () => { + expect(() => serializeMessages([createUserMessage({ + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', bytes: 68, width: 1, height: 1, + }, + }], + source: { kind: 'plugin', plugin: 'test' }, + })])).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('emits an empty user message rather than dropping block-less messages', () => { const wire = serializeMessages([createUserMessage({ content: [], diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index cbe385b7dc..9804a35a0f 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -25,6 +25,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-credentials": "^0.0.1", "@deepseek-ai/dsh-environment": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", @@ -38,6 +39,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 09c72719ec..66964c5339 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -33,6 +33,7 @@ import type { } from '@earendil-works/pi-ai' import { attributionHeaders, + contentHasImage, LlmAdapter, LlmError, ReasoningEffortId, @@ -46,6 +47,7 @@ import type { ResolvedRetryPolicy, StreamChunk, } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ResolvedPiAiProviderProfile } from './config.ts' import { toPiContext } from './context.ts' @@ -72,6 +74,8 @@ export interface PiAiAdapterOptions { * `MISSING_CREDENTIAL` rather than falling back. */ resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise + /** Resolve the optional durable attachment service at request time. */ + resolveAttachments?: () => AttachmentStore | undefined } /** Copy profile stream knobs into pi-ai's common option vocabulary. */ @@ -239,6 +243,7 @@ export class PiAiAdapter extends LlmAdapter { provider, id: model.id, name: model.name, + inputModalities: [...model.input], })) }) } @@ -260,6 +265,7 @@ export class PiAiAdapter extends LlmAdapter { provider, id: model, name: resolvedModel.name, + inputModalities: [...resolvedModel.input], context: { contextWindow: resolvedModel.contextWindow }, ...configuredMaxTokens === undefined ? {} : { defaultMaxTokens: configuredMaxTokens }, ...reasoningInfo(resolvedModel, defaultLevel), @@ -293,7 +299,18 @@ export class PiAiAdapter extends LlmAdapter { using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT') try { - const events = snapshot.models.streamSimple(model, toPiContext(options), { + const containsImage = options.messages.some(message => contentHasImage(message.content)) + if (containsImage && !model.input.includes('image')) { + throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT') + } + const attachments = containsImage ? this.config.resolveAttachments?.() : undefined + if (containsImage && attachments === undefined) { + throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + const context = attachments === undefined + ? toPiContext(options) + : await toPiContext(options, attachments) + const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, ...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index ddb8284448..678820510e 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -4,9 +4,10 @@ * @module dsh-llm-pi-ai/context */ -import { CallId } from '@deepseek-ai/dsh-llm' -import type { GenerateOptions, Message } from '@deepseek-ai/dsh-llm' -import type { Context as PiContext, Message as PiMessage, Tool as PiTool } from '@earendil-works/pi-ai' +import { CallId, contentHasImage, LlmError } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { Context as PiContext, ImageContent, Message as PiMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' /** Join the text blocks of a harness message. */ @@ -17,20 +18,137 @@ function flattenText(message: Message): string { .join('') } + +/** Flatten text recursively inside one tool result. */ +function toolResultText(blocks: readonly ContentBlock[]): string { + return blocks.map(block => block.type === 'text' + ? block.text + : block.type === 'tool-result' ? toolResultText(block.content) : '').join('') +} + +async function userContent( + blocks: readonly ContentBlock[], + attachments: AttachmentStore, +): Promise { + const content: (TextContent | ImageContent)[] = [] + for (const block of blocks) { + switch (block.type) { + case 'text': + if (block.text.length > 0) content.push({ type: 'text', text: block.text }) + break + case 'image': { + const stored = await attachments.readImage(block.attachment) + content.push({ + type: 'image', + data: Buffer.from(stored.data).toString('base64'), + mimeType: stored.ref.mediaType, + }) + break + } + case 'tool-result': + { + const nested = await userContent(block.content, attachments) + if (typeof nested === 'string') { + if (nested.length > 0) content.push({ type: 'text', text: nested }) + } else { + content.push(...nested) + } + } + break + default: + // Other merge-extensible blocks are not user-input vocabulary for pi-ai. + break + } + } + if (content.every(block => block.type === 'text')) return content.map(block => block.text).join('') + return content +} + +function toolsOf(options: GenerateOptions): PiTool[] | undefined { + return options.tools?.map(tool => ({ + name: tool.name, + description: tool.description, + // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema + // (TypeBox) is structurally JSON Schema, so it assigns directly. + parameters: tool.parameters, + })) +} + +/** Assemble the request-level pi-ai context envelope shared by both conversion paths. */ +function piContext(options: GenerateOptions, messages: PiMessage[]): PiContext { + const tools = toolsOf(options) + return { + ...options.system !== undefined ? { systemPrompt: options.system } : {}, + messages, + ...tools !== undefined && tools.length > 0 ? { tools } : {}, + } +} + +function textOnlyContext(options: GenerateOptions): PiContext { + const toolNames = new Map() + const messages: PiMessage[] = [] + for (const message of options.messages) { + if (contentHasImage(message.content)) { + throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') + } + if (message.role === 'system') { + messages.push({ role: 'user', content: flattenText(message), timestamp: 0 }) + continue + } + if (message.role === 'assistant') { + const assistant = toPiAssistant(message) + for (const block of assistant.content) if (block.type === 'toolCall') toolNames.set(CallId(block.id), block.name) + messages.push(assistant) + continue + } + const text = flattenText(message) + const results = message.content.filter(block => block.type === 'tool-result') + if (text.length > 0 || results.length === 0) messages.push({ role: 'user', content: text, timestamp: 0 }) + for (const result of results) { + messages.push({ + role: 'toolResult', + toolCallId: result.toolCallId, + toolName: toolNames.get(result.toolCallId) ?? 'unknown', + content: [{ + type: 'text', + text: toolResultText(result.content) || '(no output)', + }], + isError: result.isError ?? false, + timestamp: 0, + }) + } + } + return piContext(options, messages) +} + /** - * Convert harness history to a pi-ai Context. Tool results need the tool - * NAME (pi-ai's `toolName`), which the harness doesn't carry on the result - * block — it is recovered from the preceding assistant tool-call with the - * same id. + * Convert text-only harness history to a synchronous pi-ai Context. Tool + * result names are recovered from preceding assistant tool calls. * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. - * @returns the pi-ai context; `tools` is omitted entirely when the request declares none. + * @returns the pi-ai context; `tools` is omitted when the request declares none. */ -export function toPiContext(options: GenerateOptions): PiContext { +export function toPiContext(options: GenerateOptions): PiContext +/** + * Convert harness history to a pi-ai Context while resolving durable images. + * Tool result names are recovered from preceding assistant tool calls. + * @param options - the harness request; `options.system` maps to pi-ai's single `systemPrompt` slot. + * @param attachments - durable byte resolver for image references. + * @returns the asynchronously resolved pi-ai context. + */ +export function toPiContext(options: GenerateOptions, attachments: AttachmentStore): Promise +export function toPiContext(options: GenerateOptions, attachments?: AttachmentStore): PiContext | Promise { + return attachments === undefined ? textOnlyContext(options) : toPiContextWithImages(options, attachments) +} + +async function toPiContextWithImages(options: GenerateOptions, attachments: AttachmentStore): Promise { const toolNames = new Map() const messages: PiMessage[] = [] for (const message of options.messages) { if (message.role === 'system') { + if (contentHasImage(message.content)) { + throw new LlmError('pi-ai cannot represent an image in an in-history system message', 'UNSUPPORTED_CONTENT') + } // pi-ai has a single systemPrompt slot; in-history system messages are // folded into user messages to preserve order (rare in practice — the // harness sends the system prompt via options.system). @@ -46,40 +164,26 @@ export function toPiContext(options: GenerateOptions): PiContext { continue } // user role: text + tool results (each result becomes its own message). - const text = flattenText(message) + const regular = message.content.filter(block => block.type !== 'tool-result') + const content = await userContent(regular, attachments) const results = message.content.filter(block => block.type === 'tool-result') - if (text.length > 0 || results.length === 0) { - messages.push({ role: 'user', content: text, timestamp: 0 }) + if (content.length > 0 || results.length === 0) { + messages.push({ role: 'user', content, timestamp: 0 }) } for (const result of results) { + const resultContent = await userContent(result.content, attachments) messages.push({ role: 'toolResult', toolCallId: result.toolCallId, toolName: toolNames.get(result.toolCallId) ?? 'unknown', - content: [{ - type: 'text', - text: result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') || '(no output)', - }], + content: typeof resultContent === 'string' + ? [{ type: 'text', text: resultContent || '(no output)' }] + : resultContent, isError: result.isError ?? false, timestamp: 0, }) } } - const tools: PiTool[] | undefined = options.tools?.map(tool => ({ - name: tool.name, - description: tool.description, - // ToolSchema.parameters is a JSON Schema object; pi-ai's TSchema - // (TypeBox) is structurally JSON Schema, so it assigns directly. - parameters: tool.parameters, - })) - - return { - ...options.system !== undefined ? { systemPrompt: options.system } : {}, - messages, - ...tools !== undefined && tools.length > 0 ? { tools } : {}, - } + return piContext(options, messages) } diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index c6eac9e263..9deea3e1a8 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -184,7 +184,11 @@ export function apply(ctx: Context, config: Config): void { ) } - const adapter = new PiAiAdapter({ profiles, resolveApiKey }) + const adapter = new PiAiAdapter({ + profiles, + resolveApiKey, + resolveAttachments: () => ctx.get('attachments'), + }) // The full installed catalog is configurable from the moment the plugin // mounts — dormant or not — so configuration surfaces can offer every // pi-ai provider before any route exists. Hand-declared routes join it as diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index dc8d8443ee..10a39c655b 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -135,6 +135,8 @@ function foreignAssistant(message: Message): AssistantMessage { name: block.name, arguments: parseArguments(block.arguments), }); break + case 'image': + throw new LlmError('pi-ai chat history cannot represent structured assistant image output', 'UNSUPPORTED_CONTENT') default: // plugin-added block types are not representable in pi-ai. break diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index a8a06a167d..e0b54cecd0 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,6 +1,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' -import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, ReasoningEffortId, userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' @@ -14,6 +21,14 @@ afterEach(async () => { await closeMockServers() }) +const IMAGE_REF: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + async function harness(baseURL: string, overrides: Record = {}): Promise { vi.stubEnv('PI_TEST_KEY', 'test-key') const ctx = new Context() @@ -183,6 +198,62 @@ describe('PiAiAdapter provider routing', () => { expect(server.paths).toEqual(['/v1/responses']) }) + it('resolves an attachment service mounted after the adapter when dispatching an image', async () => { + const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'expected mock failure' } }) }]) + const attachmentId = AttachmentId(`sha256:${'a'.repeat(64)}`) + const ref: ImageAttachmentRef = { + attachmentId, + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } + const readImage = vi.fn((_ref: ImageAttachmentRef): Promise => + Promise.resolve({ ref, data: Uint8Array.of(1) })) + + class LateAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('not used')) + } + + readImage(value: ImageAttachmentRef): Promise { + return readImage(value) + } + } + + const ctx = new Context() + await ctx.plugin(LlmService) + await ctx.plugin(LlmPiAi, { + providers: { openai: { apiKeyEnv: 'PI_TEST_KEY', baseURL: `${server.url}/v1` } }, + }) + await ctx.plugin(LateAttachmentStore) + + const result = await assemble(ctx, { + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: ref }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }) + + expect(result.finish.kind).toBe('error') + expect(readImage).toHaveBeenCalledWith(ref) + expect(server.paths).toEqual(['/v1/responses']) + }) + it('forces one wire request for an SDK-retryable provider failure', async () => { const server = await mockServer([ { @@ -333,6 +404,7 @@ describe('provider profile lifecycle', () => { const models = await ctx.llm.listModels('openai') expect(models.find(model => model.id === 'gpt-4.1')).toEqual({ provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1', + inputModalities: ['text', 'image'], }) expect(models.every(model => model.provider === 'openai')).toBe(true) const info = await ctx.llm.resolveModelInfo('openai', 'gpt-4.1') @@ -693,6 +765,46 @@ describe('provider profile lifecycle', () => { expect(new LlmError('x', 'X')).toBeInstanceOf(Error) }) + it('rejects unsupported or unresolved image input before provider I/O', async () => { + const adapter = adapterOf({ openai: {}, deepseek: {} }) + const drain = async (options: Parameters[0]): Promise => { + for await (const _chunk of adapter.stream(options)) { /* drain */ } + } + + await expect(drain({ + provider: 'deepseek', + model: 'deepseek-v4-flash', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: IMAGE_REF }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ type: 'image', attachment: IMAGE_REF }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + await expect(drain({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: 'call-outer' as never, + content: [{ + type: 'tool-result', + toolCallId: 'call-inner' as never, + content: [{ type: 'image', attachment: IMAGE_REF }], + }], + }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + }) + it('validates profiles at the shared resolver boundary', () => { expect(() => resolveProfiles({ openai: { streamIdleTimeoutMs: 0 }, @@ -706,7 +818,7 @@ describe('provider profile lifecycle', () => { describe('abort wiring', () => { it('preserves an unknown pre-dispatch adapter Error exactly', async () => { const original = new Error('SDK context conversion exploded') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { throw original }, }) const adapter = adapterOf({ deepseek: {} }) @@ -724,7 +836,7 @@ describe('abort wiring', () => { it('lets a concurrent caller abort classify a pre-dispatch adapter failure', async () => { const controller = new AbortController() const original = new Error('conversion lost its caller') - const message = Object.defineProperty({}, 'role', { + const message = Object.defineProperty({}, 'content', { get() { controller.abort('caller cancelled during conversion') throw original diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index 14cb10df76..e304dddade 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -100,7 +100,7 @@ describe('hand-declared providers', () => { const ctx = await harness(gateway(`${server.url}/v1`)) expect(await ctx.llm.listModels('acme-gateway')).toEqual([ - { provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large' }, + { provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large', inputModalities: ['text'] }, ]) const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large') expect(info).toMatchObject({ diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts new file mode 100644 index 0000000000..01ed3d080c --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -0,0 +1,186 @@ +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { CallId, createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' +import { toPiContext } from '../src/context.ts' +import { toPiAssistant } from '../src/replay.ts' + +const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, +} + +const attachments = { + readImage: vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1) })), +} as unknown as AttachmentStore + +function request(messages: GenerateOptions['messages']): GenerateOptions { + return { + provider: 'openai', + model: 'gpt-4.1', + system: 'system prompt', + tools: [{ name: 'lookup', description: 'look up', parameters: { type: 'object' } }], + messages, + } +} + +function user(content: ContentBlock[]): Message { + return createUserMessage({ content, source: { kind: 'plugin', plugin: 'test' } }) +} + +function history(role: 'system' | 'assistant', content: ContentBlock[]): Message { + return createMessage({ role, content, source: { kind: 'plugin', plugin: 'test' } }) +} + +describe('pi-ai request context conversion', () => { + it('omits absent and empty request-level optional fields', () => { + const base = { provider: 'openai', model: 'gpt-4.1', messages: [] } + expect(toPiContext(base)).toEqual({ messages: [] }) + expect(toPiContext({ ...base, tools: [] })).toEqual({ messages: [] }) + }) + + it('converts complete text-only history and rejects nested images without storage', () => { + const callId = CallId('call-1') + expect(toPiContext(request([ + history('system', [{ type: 'text', text: 'history system' }]), + history('assistant', [{ type: 'tool-call', id: callId, name: 'lookup', arguments: '{}' }]), + user([ + { type: 'text', text: 'after tool' }, + { + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: '' }], + }, + ]), + ]))).toMatchObject({ + systemPrompt: 'system prompt', + tools: [{ name: 'lookup' }], + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'after tool' }, + { + role: 'toolResult', + toolCallId: 'call-1', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + }, + ], + }) + + expect(() => toPiContext(request([user([{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'image', attachment: ref }], + }])]))).toThrow(/durable attachment service/) + }) + + it('resolves user and tool-result images while preserving explicit fallbacks', async () => { + const callId = CallId('missing-call') + const knownCallId = CallId('known-call') + const context = await toPiContext(request([ + user([{ type: 'text', text: '' }]), + history('assistant', [ + { type: 'text', text: 'calling' }, + { type: 'tool-call', id: knownCallId, name: 'lookup', arguments: '{}' }, + ]), + user([ + { type: 'image', attachment: ref }, + { type: 'text', text: 'caption' }, + { type: 'reasoning', text: 'ignored' }, + ]), + user([{ + type: 'tool-result', + toolCallId: knownCallId, + content: [{ type: 'text', text: '' }], + }]), + user([{ + type: 'tool-result', + toolCallId: callId, + isError: true, + content: [ + { type: 'tool-result', toolCallId: callId, content: [] }, + { type: 'image', attachment: ref }, + ], + }]), + ]), attachments) + + expect(context.messages).toEqual([ + { role: 'user', content: '', timestamp: 0 }, + expect.objectContaining({ role: 'assistant' }), + { + role: 'user', + content: [ + { type: 'image', data: 'AQ==', mimeType: 'image/png' }, + { type: 'text', text: 'caption' }, + ], + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'known-call', + toolName: 'lookup', + content: [{ type: 'text', text: '(no output)' }], + isError: false, + timestamp: 0, + }, + { + role: 'toolResult', + toolCallId: 'missing-call', + toolName: 'unknown', + content: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + isError: true, + timestamp: 0, + }, + ]) + }) + + it('keeps empty text-only users while separating result-only messages', () => { + const callId = CallId('unknown-call') + expect(toPiContext(request([ + user([]), + history('assistant', [ + { type: 'text', text: 'answer' }, + { type: 'tool-call', id: CallId('other-call'), name: 'lookup', arguments: '{}' }, + ]), + user([{ + type: 'tool-result', + toolCallId: callId, + content: [{ type: 'text', text: 'result' }], + }]), + ]))).toMatchObject({ + messages: [ + { role: 'user', content: '' }, + { role: 'assistant' }, + { role: 'toolResult', toolName: 'unknown' }, + ], + }) + }) + + it('handles in-history system and assistant messages explicitly on the image path', async () => { + await expect(toPiContext(request([ + history('system', [{ type: 'image', attachment: ref }]), + ]), attachments)).rejects.toMatchObject({ code: 'UNSUPPORTED_CONTENT' }) + + await expect(toPiContext(request([ + history('system', [{ type: 'text', text: 'history system' }]), + history('assistant', [{ type: 'text', text: 'answer' }]), + user([{ type: 'text', text: 'plain' }]), + ]), attachments)).resolves.toMatchObject({ + messages: [ + { role: 'user', content: 'history system' }, + { role: 'assistant' }, + { role: 'user', content: 'plain' }, + ], + }) + + expect(() => toPiAssistant( + history('assistant', [{ type: 'image', attachment: ref }]), + )).toThrow(/assistant image output/) + }) +}) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index b71d67b6c7..5af58e6630 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' -import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError , createMessage } from '@deepseek-ai/dsh-llm' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { createUserMessage, CallId, CONTEXT_WINDOW_EXCEEDED_CODE, EMPTY_RESPONSE_CODE, LlmError, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, StreamChunk } from '@deepseek-ai/dsh-llm' import type { AssistantMessage, AssistantMessageEvent, Usage } from '@earendil-works/pi-ai' import { toPiContext } from '../src/context.ts' @@ -66,6 +68,100 @@ describe('toPiContext', () => { expect(context.tools).toBeUndefined() }) + it('resolves durable image references into native pi-ai image content', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ type: 'text', text: 'describe' }, { type: 'image', attachment }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }, { readImage } as unknown as AttachmentStore) + + expect(readImage).toHaveBeenCalledWith(attachment) + expect(context.messages[0]).toEqual({ + role: 'user', + content: [ + { type: 'text', text: 'describe' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + ], + timestamp: 0, + }) + }) + + it('flattens nested tool-result images into the enclosing result', async () => { + const attachment = { + attachmentId: AttachmentId(`sha256:${'c'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 3, + width: 1, + height: 1, + } + const readImage = vi.fn().mockResolvedValue({ ref: attachment, data: Uint8Array.of(1, 2, 3) }) + const context = await toPiContext({ + provider: 'openai', + model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ + type: 'tool-result', + toolCallId: CallId('outer'), + content: [ + { type: 'tool-result', toolCallId: CallId('empty'), content: [] }, + { type: 'text', text: 'before' }, + { type: 'tool-result', toolCallId: CallId('text'), content: [{ type: 'text', text: 'middle' }] }, + { + type: 'tool-result', + toolCallId: CallId('inner'), + content: [ + { type: 'image', attachment }, + { type: 'text', text: 'after' }, + ], + }, + ], + }], + source: { kind: 'plugin', plugin: 'test' }, + })], + }, { readImage } as unknown as AttachmentStore) + + expect(context.messages).toEqual([{ + role: 'toolResult', + toolCallId: 'outer', + toolName: 'unknown', + content: [ + { type: 'text', text: 'before' }, + { type: 'text', text: 'middle' }, + { type: 'image', data: 'AQID', mimeType: 'image/png' }, + { type: 'text', text: 'after' }, + ], + isError: false, + timestamp: 0, + }]) + }) + + it('rejects structured image history when no durable resolver is supplied', () => { + expect(() => toPiContext({ + provider: 'openai', model: 'gpt-4.1', + messages: [createUserMessage({ + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId(`sha256:${'b'.repeat(64)}`), + mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }], + source: { kind: 'plugin', plugin: 'test' }, + })], + })).toThrow(expect.objectContaining({ code: 'UNSUPPORTED_CONTENT' })) + }) + it('maps assistant text/reasoning/tool-call blocks', () => { const context = toPiContext({ provider: 'deepseek', @@ -158,7 +254,15 @@ describe('toPiContext', () => { source: { kind: 'plugin', plugin: 'test' }, }), createUserMessage({ - content: [{ type: 'tool-result', toolCallId: CallId('c1'), content: [{ type: 'text', text: 'Sunny' }] }], + content: [{ + type: 'tool-result', + toolCallId: CallId('c1'), + content: [ + { type: 'text', text: 'Sunny' }, + { type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'text', text: '!' }] }, + { type: 'chart', data: 'ignored' } as unknown as ContentBlock, + ], + }], source: { kind: 'plugin', plugin: 'test' }, }), ], @@ -167,7 +271,7 @@ describe('toPiContext', () => { role: 'toolResult', toolCallId: 'c1', toolName: 'get_weather', - content: [{ type: 'text', text: 'Sunny' }], + content: [{ type: 'text', text: 'Sunny!' }], isError: false, timestamp: 0, }) diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index f59859bfad..002de2b829 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,6 +1,14 @@ +import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' import { Context } from 'cordis' -import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import type { PiAiReplayState } from '../src/replay.ts' @@ -17,6 +25,10 @@ interface ProviderCase { const openAIBaseURL = process.env.DSH_PI_AI_OPENAI_BASE_URL const azureOpenAIKey = process.env.AZURE_OPENAI_API_KEY +// Strictly ANTHROPIC_*: the DeepSeek endpoint does not serve the anthropic-messages +// protocol, so falling back to DEEPSEEK_API_KEY turns the keyless skip into a 404. +const anthropicApiKey = process.env.ANTHROPIC_API_KEY +const anthropicBaseURL = process.env.DSH_PI_AI_ANTHROPIC_BASE_URL const providerCases: ProviderCase[] = [ { @@ -32,13 +44,14 @@ const providerCases: ProviderCase[] = [ provider: 'anthropic', api: 'anthropic-messages', model: process.env.DSH_PI_AI_ANTHROPIC_MODEL ?? 'claude-opus-4-8', - ...process.env.ANTHROPIC_API_KEY ? { apiKey: process.env.ANTHROPIC_API_KEY } : {}, + ...anthropicApiKey === undefined ? {} : { apiKey: anthropicApiKey }, + ...anthropicBaseURL === undefined ? {} : { baseURL: anthropicBaseURL }, }, ] const contexts: Context[] = [] -async function harness(): Promise { +async function harness(image?: StoredImageAttachment): Promise { const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmService) @@ -49,6 +62,34 @@ async function harness(): Promise { ...profile.headers === undefined ? {} : { headers: profile.headers }, }])), }) + if (image !== undefined) { + const fixture = image + class E2eAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: fixture.data.byteLength, + maxImagesPerMessage: 1, + maxMessageImageBytes: fixture.data.byteLength, + maxImagePixels: fixture.ref.width * fixture.ref.height, + mediaTypes: [fixture.ref.mediaType], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('e2e attachment fixture is read-only')) + } + + readImage(ref: ImageAttachmentRef): Promise { + if (ref.attachmentId !== fixture.ref.attachmentId) { + return Promise.reject(new Error('unknown e2e attachment fixture')) + } + return Promise.resolve(fixture) + } + } + await ctx.plugin(E2eAttachmentStore) + } return ctx } @@ -162,6 +203,41 @@ for (const profile of providerCases) { expect(textOf(second).toLowerCase()).toContain('ocean') expect(expectNativeReplay(second, profile).stopReason).toBe('stop') }) + + if (profile.provider === 'anthropic') { + it('sends a real image through the authenticated Anthropic visual path', async () => { + const data = new Uint8Array(await readFile( + new URL('../../../../assets/community-wecom-survey.png', import.meta.url), + )) + const ref: ImageAttachmentRef = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png', + bytes: data.byteLength, + width: 256, + height: 256, + name: 'qr-code.png', + } + const ctx = await harness({ ref, data }) + const result = await assemble(ctx, { + provider: profile.provider, + model: profile.model, + messages: [createUserMessage({ + content: [ + { + type: 'text', + text: 'What type of machine-readable symbol is shown in the attached image? Reply with exactly: QR code', + }, + { type: 'image', attachment: ref }, + ], + source: { kind: 'plugin', plugin: 'test' }, + })], + maxTokens: 256, + }) + + expectFinish(result, 'stop') + expect(textOf(result).toLowerCase()).toContain('qr code') + }) + } }, ) } diff --git a/packages/llm/llm-pi-ai/tsconfig.json b/packages/llm/llm-pi-ai/tsconfig.json index dd364e493a..ec3ecb2ebc 100644 --- a/packages/llm/llm-pi-ai/tsconfig.json +++ b/packages/llm/llm-pi-ai/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../llm/llm" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../credentials/credentials" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index c0a6994240..c802ce5c43 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -38,6 +38,7 @@ ], "license": "BSD-3-Clause", "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-brand": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-timeout": "^0.0.1", @@ -47,6 +48,7 @@ "schemastery": "^3.18.0" }, "devDependencies": { + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", diff --git a/packages/llm/llm/src/content.ts b/packages/llm/llm/src/content.ts new file mode 100644 index 0000000000..19b760a02a --- /dev/null +++ b/packages/llm/llm/src/content.ts @@ -0,0 +1,16 @@ +/** Content-block structure helpers. @module @deepseek-ai/dsh-llm/content */ + +import type { ContentBlock } from './types.ts' + +/** + * True when typed model content contains an image block, walking nested + * tool-result content. This is the one recursive image walk shared by every + * image policy (capability gating, text-only serialization, compaction + * survey), so a consumer cannot silently diverge on nesting depth. + * @param content - typed model content blocks. + * @returns whether any nested block is an image. + */ +export function contentHasImage(content: readonly ContentBlock[]): boolean { + return content.some(block => block.type === 'image' + || (block.type === 'tool-result' && contentHasImage(block.content))) +} diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 0d1f23af19..8a7dec1265 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -17,6 +17,7 @@ import type { LlmModelInfo, LlmResolvedModelInfo, LlmProviderInfo, + ModelModality, StreamChunk, } from './types.ts' import { freezeMessage, type Message } from './message.ts' @@ -35,6 +36,7 @@ export * from './never.ts' export * from './error.ts' export * from './api-key.ts' export * from './types.ts' +export * from './content.ts' export * from './message.ts' export * from './retry-policy.ts' export { BlockAssembler } from './assembler.ts' @@ -575,6 +577,11 @@ export class LlmService extends Service { return this.registration(provider).retryPolicy } + /** Detach typed adapter-owned modality metadata. */ + private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined { + return modalities === undefined ? undefined : [...modalities] + } + /** * Discover models advertised by one registered provider. Catalog membership * is advisory and never changes routing or request validation. @@ -599,11 +606,13 @@ export class LlmService extends Service { throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG') } seen.add(model.id) + const inputModalities = this.detachedModalities(model.inputModalities) return { provider: model.provider, id: model.id, name: model.name, ...model.description === undefined ? {} : { description: model.description }, + ...inputModalities === undefined ? {} : { inputModalities }, } }) } @@ -653,6 +662,9 @@ export class LlmService extends Service { 'INVALID_MODEL_CONTEXT', ) } + // Capability metadata rides through: an explicit modality omission is + // negative capability downstream preflights act on (image admission). + const inputModalities = this.detachedModalities(resolved.inputModalities) const defaultMaxTokens = resolved.defaultMaxTokens if (defaultMaxTokens !== undefined && (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) { @@ -666,6 +678,7 @@ export class LlmService extends Service { id: model, name: resolved.name, ...resolved.description === undefined ? {} : { description: resolved.description }, + ...inputModalities === undefined ? {} : { inputModalities }, ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } }, ...defaultMaxTokens === undefined ? {} : { defaultMaxTokens }, } diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts index 959190bbbb..ad7e8f66ba 100644 --- a/packages/llm/llm/src/types.ts +++ b/packages/llm/llm/src/types.ts @@ -5,6 +5,7 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { CallId, ProviderRequestId, ReasoningEffortId } from './brand.ts' import type { Message } from './message.ts' @@ -46,6 +47,18 @@ export interface ReasoningBlock { text: string } +/** + * A durable raster image reference, valid in user or assistant content. The + * block is deliberately role-neutral; assistant-side rendering is forward + * compatibility — the current production adapters declare text-only output, + * so only user content carries images today. + */ +export interface ImageBlock { + type: 'image' + /** Immutable bytes and intrinsic display metadata owned by the attachment service. */ + attachment: ImageAttachmentRef +} + /** A tool invocation requested by the model. */ export interface ToolCallBlock { type: 'tool-call' @@ -71,6 +84,7 @@ export interface ToolResultBlock { export interface ContentBlockMap { 'text': TextBlock 'reasoning': ReasoningBlock + 'image': ImageBlock 'tool-call': ToolCallBlock 'tool-result': ToolResultBlock } @@ -119,6 +133,15 @@ export interface LlmProviderInfo { name: string } +/** Merge-extensible provider model modality vocabulary. */ +export interface ModelModalityMap { + text: 'text' + image: 'image' +} + +/** Any declared provider model modality. */ +export type ModelModality = ModelModalityMap[keyof ModelModalityMap] + /** * One provider route an adapter plugin can activate through configuration, * whether or not the route is currently registered. Configuration surfaces @@ -201,6 +224,8 @@ export interface LlmModelInfo { name: string /** Optional user-facing distinction from otherwise similar models. */ description?: string + /** Accepted request modalities; absent means unknown, while an explicit omission is negative capability. */ + inputModalities?: readonly ModelModality[] } /** Provider-owned context capacity for one exact provider/model route. */ diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index 414c50cd28..a758fab415 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -576,6 +576,27 @@ describe('LlmService', () => { .rejects.toMatchObject({ code: 'INVALID_MODEL_INFO' }) }) + it('preserves modality metadata through exact model resolution', async () => { + const ctx = new Context() + await ctx.plugin(LlmService) + const adapter = new class extends ScriptedAdapter { + override resolveModel(): Promise { + return Promise.resolve({ + provider: 'route', id: 'model', name: 'Model', + inputModalities: ['text', 'image'], + }) + } + }(SCRIPT) + ctx.llm.registerAdapter(['route'], adapter) + + // Downstream preflights (image admission) act on this exact field; a + // rebuild that drops it silently reads as "modalities unknown". + await expect(ctx.llm.resolveModelInfo('route', 'model')).resolves.toEqual({ + provider: 'route', id: 'model', name: 'Model', + inputModalities: ['text', 'image'], + }) + }) + it('resolves detached model context independently of advisory catalog membership', async () => { const ctx = new Context() await ctx.plugin(LlmService) diff --git a/packages/llm/llm/tsconfig.json b/packages/llm/llm/tsconfig.json index fa4adda095..69b527f497 100644 --- a/packages/llm/llm/tsconfig.json +++ b/packages/llm/llm/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../../util/brand" }, + { + "path": "../../attachment/attachment" + }, { "path": "../../support/invariants" }, diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index 5638ce642b..e0bf348019 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it } from 'vitest' import { Context } from 'cordis' -import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' +import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index a796f3e48a..4b95f4da58 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -34,6 +34,7 @@ const WORKSPACE_CLOSURE = [ 'packages/sandbox/sandbox-windows-acl', 'packages/sandbox/sandbox', 'packages/llm/llm', + 'packages/attachment/attachment', 'packages/util/brand', 'packages/util/timeout', 'packages/support/invariants', diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 86def75601..fdb852533d 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -216,6 +216,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'attachments', + summary: 'Immutable binary attachment service.', + methods: [ + { + signature: 'abstract validateImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate one image without persisting it.\n * Batch callers validate every member before saving any member.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns completion after the encoded raster has been fully decoded.\n */', + }, + { + signature: 'abstract saveImage(input: SaveImageAttachment): Promise', + jsDoc: '/**\n * Validate and durably commit one image before its owning session event is appended.\n * @param input - encoded bytes, declared media type, and optional display name.\n * @returns a durable content-addressed reference.\n */', + }, + { + signature: 'abstract readImage(ref: ImageAttachmentRef): Promise', + jsDoc: '/**\n * Read one image and verify that bytes still match the recorded reference.\n * @param ref - durable reference from the session log.\n * @returns the verified bytes and canonical reference.\n */', + }, + ], + }, { key: 'bash', summary: 'Abstract bash execution service.', @@ -1785,6 +1803,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'AssistantProvenance', declaration: 'export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n}', }, + { + name: 'AttachmentId', + declaration: 'export type AttachmentId = Branded<\'AttachmentId\'>;', + }, { name: 'BashEnvContributor', declaration: 'export interface BashEnvContributor {\n name: string;\n variables: Readonly>;\n resolve(execution: ToolExecution): Readonly>>;\n}', @@ -1923,7 +1945,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'ContentBlockMap', - declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', + declaration: 'export interface ContentBlockMap {\n \'text\': TextBlock;\n \'reasoning\': ReasoningBlock;\n \'image\': ImageBlock;\n \'tool-call\': ToolCallBlock;\n \'tool-result\': ToolResultBlock;\n}', }, { name: 'ContentBlockType', @@ -2161,6 +2183,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'GoalView', declaration: 'export interface GoalView extends GoalSnapshot {\n readonly roundsStarted: number;\n readonly createdAt: number;\n readonly updatedAt: number;\n readonly activation: GoalActivation;\n}', }, + { + name: 'ImageAttachmentRef', + declaration: 'export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n}', + }, + { + name: 'ImageBlock', + declaration: 'export interface ImageBlock {\n type: \'image\';\n attachment: ImageAttachmentRef;\n}', + }, + { + name: 'ImageMediaType', + declaration: 'export type ImageMediaType = \'image/png\' | \'image/jpeg\' | \'image/webp\' | \'image/gif\';', + }, { name: 'Inbox', declaration: 'export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n}', @@ -2259,7 +2293,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'LlmModelInfo', - declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n}', + declaration: 'export interface LlmModelInfo {\n provider: string;\n id: string;\n name: string;\n description?: string;\n inputModalities?: readonly ModelModality[];\n}', }, { name: 'LlmModelReasoningInfo', @@ -2301,6 +2335,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'ModelMessageSource', declaration: 'export interface ModelMessageSource extends AssistantProvenance {\n kind: \'model\';\n}', }, + { + name: 'ModelModality', + declaration: 'export type ModelModality = ModelModalityMap[keyof ModelModalityMap];', + }, + { + name: 'ModelModalityMap', + declaration: 'export interface ModelModalityMap {\n text: \'text\';\n image: \'image\';\n}', + }, { name: 'ModelSelection', declaration: 'export interface ModelSelection {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n}', @@ -2541,6 +2583,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SandboxPolicyRequest', declaration: 'export interface SandboxPolicyRequest {\n session?: Session;\n mode?: SandboxMode;\n}', }, + { + name: 'SaveImageAttachment', + declaration: 'export interface SaveImageAttachment {\n data: Uint8Array;\n mediaType: ImageMediaType;\n name?: string;\n}', + }, { name: 'SaveTextSpill', declaration: 'export interface SaveTextSpill {\n owner: SpillOwner;\n source: SpillSource;\n suggestedName: string;\n content: string;\n}', @@ -2889,6 +2935,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'StorageForms', declaration: 'export interface StorageForms {\n}', }, + { + name: 'StoredImageAttachment', + declaration: 'export interface StoredImageAttachment {\n ref: ImageAttachmentRef;\n data: Uint8Array;\n}', + }, { name: 'StreamChunk', declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};', diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index d6d459a4d7..0b214fc311 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -45,7 +45,7 @@ async function harness(withTodoTool: boolean): Promise { if (withTodoTool) await ctx.plugin(ToolTodo, { allowParallelInProgress: true }) const session = ctx.sessions.create() ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) - const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }) + const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) return { ctx, session, diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index ddd2b901b3..d2d20d65ab 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -139,6 +139,11 @@ export class WorkspaceRegistry extends Service { * @param title - Display title used only when a new record is created. * @returns the existing or newly durable workspace. */ + // TODO: `title` lost its last production caller when the gateway's + // create-by-name branch was deleted + // (.agents/notes/implemented/simplification/2026-07-31-one-route-to-add-a-workspace.md); + // drop the parameter with its @param clause and the `create(path, title?)` + // lines in this package's README pair. async create(path: string, title?: string): Promise { const canonical = await realpathNormalize(path) if (!(await stat(canonical)).isDirectory()) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 072064f91e..7de565e0c9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -819,6 +819,40 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/attachment/attachment: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + + packages/attachment/attachment-local: + dependencies: + schemastery: + specifier: ^3.18.0 + version: link:../../../vendor/schemastery + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.0) + devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../attachment + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-paths': + specifier: workspace:^ + version: link:../../util/paths + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/bash/bash: devDependencies: '@deepseek-ai/dsh-invariants': @@ -1157,6 +1191,9 @@ importers: '@deepseek-ai/dsh-api-gateway': specifier: workspace:^ version: link:../../api/gateway + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:^ + version: link:../../attachment/attachment-local '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -1553,6 +1590,9 @@ importers: packages/client/connection: dependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands @@ -1654,6 +1694,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1866,6 +1909,12 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -4177,6 +4226,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4551,6 +4603,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4604,6 +4659,9 @@ importers: specifier: ^3.18.0 version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -7481,6 +7539,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/boot/app-boot + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../packages/attachment/attachment '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../packages/bash/bash @@ -8412,6 +8473,9 @@ packages: '@emnapi/runtime@1.11.0': resolution: {integrity: sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -8951,6 +9015,168 @@ packages: '@iconify/utils@3.1.3': resolution: {integrity: sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.3': + resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.3': + resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.3': + resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.2': + resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.2': + resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.2': + resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.2': + resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.2': + resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.2': + resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.2': + resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.2': + resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.3': + resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.3': + resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.3': + resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.3': + resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.3': + resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.3': + resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.3': + resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.3': + resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.3': + resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.3': + resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.3': + resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.3': + resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.3': + resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -12562,6 +12788,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -12576,6 +12807,15 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.35.3: + resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -13944,6 +14184,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -14256,6 +14501,112 @@ snapshots: '@iconify/types': 2.0.0 import-meta-resolve: 4.2.0 + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.2 + optional: true + + '@img/sharp-darwin-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.2 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.2': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.2': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.2': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.2': + optional: true + + '@img/sharp-linux-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.2 + optional: true + + '@img/sharp-linux-arm@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.2 + optional: true + + '@img/sharp-linux-ppc64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.2 + optional: true + + '@img/sharp-linux-riscv64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.2 + optional: true + + '@img/sharp-linux-s390x@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.2 + optional: true + + '@img/sharp-linux-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.3': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + optional: true + + '@img/sharp-wasm32@0.35.3': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.3': + dependencies: + '@img/sharp-wasm32': 0.35.3 + optional: true + + '@img/sharp-win32-arm64@0.35.3': + optional: true + + '@img/sharp-win32-ia32@0.35.3': + optional: true + + '@img/sharp-win32-x64@0.35.3': + optional: true + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -18002,6 +18353,8 @@ snapshots: semver@7.8.4: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -18031,6 +18384,39 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.35.3(@types/node@22.20.0): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.3 + '@img/sharp-darwin-x64': 0.35.3 + '@img/sharp-freebsd-wasm32': 0.35.3 + '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-linux-arm': 0.35.3 + '@img/sharp-linux-arm64': 0.35.3 + '@img/sharp-linux-ppc64': 0.35.3 + '@img/sharp-linux-riscv64': 0.35.3 + '@img/sharp-linux-s390x': 0.35.3 + '@img/sharp-linux-x64': 0.35.3 + '@img/sharp-linuxmusl-arm64': 0.35.3 + '@img/sharp-linuxmusl-x64': 0.35.3 + '@img/sharp-webcontainers-wasm32': 0.35.3 + '@img/sharp-win32-arm64': 0.35.3 + '@img/sharp-win32-ia32': 0.35.3 + '@img/sharp-win32-x64': 0.35.3 + '@types/node': 22.20.0 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index 4b9e3b48c8..d5e90fb1b9 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -14,6 +14,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 63cf4f43ae..f2349d7c13 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -48,6 +48,7 @@ export const SERVICE_PAGE: Record = { agentPresets: 'core.md', agents: 'core.md', approval: 'approval.md', + attachments: 'attachment.md', bash: 'bash.md', bashEnv: 'bash.md', clientModuleHost: 'client-modules.md', @@ -244,6 +245,9 @@ export const LINK_MAP: Readonly> = { ApprovalPolicy: 'approval.md', ApprovalRequest: 'approval.md', ApprovalService: 'approval.md', + ImageAttachmentRef: 'attachment.md', + SaveImageAttachment: 'attachment.md', + StoredImageAttachment: 'attachment.md', BashExecRequest: 'bash.md', BashExecSpec: 'bash.md', BashProcess: 'bash.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index cbb73fdcce..e9f2f806ae 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -62,6 +62,7 @@ type EventReceiverKind = 'context' | 'agent-dispatch' | 'events-service' const GROUP_ORDER = [ 'util', + 'attachment', 'llm', 'core', 'typert', @@ -95,6 +96,15 @@ const GROUP_ORDER = [ ] const SERVICE_ROLES: ServiceRole[] = [ + { + key: 'attachments', + pkg: 'attachment', + title: 'Durable binary attachment storage', + mode: 'seam', + implementations: ['attachment-local'], + consumers: ['host-runtime', 'llm-pi-ai'], + note: 'The host commits accepted images before session events; provider adapters resolve authorized durable references into provider-native content.', + }, { key: 'llm', pkg: 'llm', diff --git a/scripts/hero-composer-dom-continuity.mjs b/scripts/hero-composer-dom-continuity.mjs deleted file mode 100644 index 187221f218..0000000000 --- a/scripts/hero-composer-dom-continuity.mjs +++ /dev/null @@ -1,78 +0,0 @@ -// Regression drive for the unified hero composer: -// cold start with zero workspaces -> create a workspace -> type. Asserts the -// composer textarea is the SAME DOM node across the disabled->live flip (a -// remount drops the __heroMark marker property) — the session-maybe -// composer.bar contract. -// -// Prereqs: `pnpm run build`, then a fresh server against empty state: -// rm -rf .storages && DSH_HOME=$(mktemp -d) node --experimental-transform-types \ -// --import ./scripts/tspath-loader.ts apps/cli/src/bin.ts web --port 44285 \ -// --workspace-root $(mktemp -d) -// Run: node scripts/hero-composer-dom-continuity.mjs -// (BASE_URL overrides the target; screenshots land in .artifacts/.) -import { createRequire } from 'node:module' - -// playwright is a devDependency of apps/web only — resolve through its tree. -const require = createRequire(new URL('../apps/web/package.json', import.meta.url)) -const { chromium } = require('playwright') - -const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:44285' -const SHOTS = new URL('../.artifacts/screenshots/0729-0357-hero-unify/', import.meta.url).pathname - -const browser = await chromium.launch() -const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) -page.on('console', msg => { if (msg.type() === 'error') console.log('[console.error]', msg.text()) }) -page.on('pageerror', err => { console.log('[pageerror]', err.message) }) - -await page.goto(BASE) -await page.waitForSelector('textarea', { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '01-cold-start.png' }) - -const initial = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - boxes.forEach((b, i) => { b.__heroMark = 'alive-' + i }) - return boxes.map(b => ({ disabled: b.disabled, placeholder: b.placeholder })) -}) -console.log('cold-start textareas:', JSON.stringify(initial)) - -// Open the picker and create a workspace by name (typed-input flow). The name -// must be unique per registry; keystrokes go through pressSequentially so the -// dialog's React onChange enables the submit button. -await page.getByRole('button', { name: 'Choose workspace' }).click() -await page.getByText('Create a new workspace').click() -await page.screenshot({ path: SHOTS + '03-create-form.png' }) -const nameBox = page.getByPlaceholder('Workspace name') -await nameBox.click() -const wsName = 'proj-' + Date.now().toString(36) -await nameBox.pressSequentially(wsName, { delay: 30 }) -await page.locator('button:text-is("Create workspace")').click() - -// Wait for the composer to go live (placeholder flips, textarea enabled). -await page.waitForFunction(() => { - const box = document.querySelector('textarea') - return box !== null && !box.disabled -}, { timeout: 20000 }) -await page.screenshot({ path: SHOTS + '04-live.png' }) - -const after = await page.evaluate(() => { - const boxes = [...document.querySelectorAll('textarea')] - return boxes.map(b => ({ - mark: b.__heroMark ?? 'REMOUNTED', - disabled: b.disabled, - placeholder: b.placeholder, - })) -}) -console.log('post-pick textareas:', JSON.stringify(after)) - -// Type into the live composer. -await page.locator('textarea').first().fill('hello from acceptance run') -const typed = await page.evaluate(() => document.querySelector('textarea')?.value) -console.log('typed value:', JSON.stringify(typed)) -await page.screenshot({ path: SHOTS + '05-typed.png' }) - -const survived = after.length === 1 && after[0].mark === 'alive-0' -console.log(survived - ? 'DOM-CONTINUITY: PASS (same textarea node across cold-start -> live)' - : 'DOM-CONTINUITY: FAIL ' + JSON.stringify(after)) -await browser.close() -process.exit(survived && typed === 'hello from acceptance run' ? 0 : 1) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 9705f590de..d6f05eee90 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -8,6 +8,13 @@ import { expect } from 'vitest' import { FiberState, Inject, RegistryService } from 'cordis' import type { Context, Plugin } from 'cordis' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import InvariantService from '@deepseek-ai/dsh-invariants' declare global { @@ -102,6 +109,29 @@ export function usesManualInvariantTree(testPath: string): boolean { } const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const +const ATTACHMENT_COMPANION = '../packages/attachment/attachment-local/src/invariant.ts' + +class TestAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = { + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: ['image/png'], + } + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not validate images')) + } + + saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('test invariant attachment store does not save images')) + } + + readImage(_ref: ImageAttachmentRef): Promise { + return Promise.reject(new Error('test invariant attachment store does not read images')) + } +} /** * Select the package companions that an ordinary test root must register. @@ -148,6 +178,9 @@ function startInvariantHost(root: Context): InvariantHost { const testPath = expect.getState().testPath ?? '' const companionPaths = testInvariantCompanionPaths(testPath) const ready = requireActive(serviceFiber, 'invariant service').then(async () => { + const attachmentFiber = companionPaths.includes(ATTACHMENT_COMPANION) + ? mount(TestAttachmentStore) + : undefined const companions = await Promise.all(companionPaths.map(async (path) => { const load = testInvariantCompanions[path] if (load === undefined) { @@ -163,7 +196,12 @@ function startInvariantHost(root: Context): InvariantHost { fiber: mount(companion), path, })) - await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path))) + await Promise.all([ + ...(attachmentFiber === undefined + ? [] + : [requireActive(attachmentFiber, 'test attachment store')]), + ...companionFibers.map(({ fiber, path }) => requireActive(fiber, path)), + ]) root.provide(TEST_INVARIANT_READY_SERVICE, true) }) const host = { byCallback, barrierOwners, ready } diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 48d0c72db7..1a9d998029 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -780,6 +780,31 @@ "symbol": "ApprovalRequest", "source": "packages/interaction/user-approval/src/index.ts" }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageMediaType", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageAttachmentRef", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "ImageAttachmentLimits", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "SaveImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, + { + "doc": "docs/subsystems/attachment.md", + "symbol": "StoredImageAttachment", + "source": "packages/attachment/attachment/src/types.ts" + }, { "doc": "docs/subsystems/bash.md", "symbol": "BashExecRequest", diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 6e69414745..1b39447215 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -42,6 +42,8 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly> = { * blocks. A package moves on or off this list with its context behavior. */ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { + 'packages/attachment/attachment': { kind: 'indirect', reason: 'The storage seam delegates model request rendering to provider adapters.' }, + 'packages/attachment/attachment-local': { kind: 'indirect', reason: 'The local backend delegates model request rendering to provider adapters.' }, 'packages/bash/bash': { kind: 'indirect', reason: 'The service interface delegates all model rendering to dsh-tool-bash.' }, 'packages/bash/bash-env': { kind: 'indirect', reason: 'The env service surfaces managed DSH_* facts through the shell tools (dsh-tool-bash/dsh-tool-pwsh); it registers no prompt or schema of its own.' }, 'packages/bash/bash-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-bash.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index d07f1d4ef9..0523b378d9 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -105,6 +105,7 @@ "./packages/tasks/*/src/invariant.ts", "./packages/workflow/*/src/invariant.ts", "./packages/web/*/src/invariant.ts", + "./packages/attachment/*/src/invariant.ts", "./packages/spill/*/src/invariant.ts", "./packages/todo/*/src/invariant.ts", "./packages/bundle/*/src/invariant.ts", @@ -215,6 +216,7 @@ "./packages/tasks/*/src", "./packages/workflow/*/src", "./packages/web/*/src", + "./packages/attachment/*/src", "./packages/spill/*/src", "./packages/todo/*/src", "./packages/bundle/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 047dc5a1bc..750b586f5f 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -105,6 +105,8 @@ { "path": "./packages/util/timeout" }, { "path": "./packages/util/retention" }, { "path": "./packages/util/atomic-write" }, + { "path": "./packages/attachment/attachment" }, + { "path": "./packages/attachment/attachment-local" }, { "path": "./packages/llm/llm" }, { "path": "./packages/llm/token-meter" }, { "path": "./packages/core/session" },