diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml index f616ad829c..a1245a8831 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md -2026-08-20-client-session-conversation-ownership.md: 8e5521ff1981d83ab72db00dea556b4b2acc97fa -2026-08-20-client-session-conversation-ownership.zh.md: a007a42b3d10ceeced8a2a64696521a96382f4e5 +2026-08-20-client-session-conversation-ownership.md: 12e137209bb43d21c3437d2ce5e9d2f9180bbc77 +2026-08-20-client-session-conversation-ownership.zh.md: f0d9861eeafc07481c536b85f5ba9667573a66ef diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md index 8e5521ff19..12e137209b 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.md @@ -82,7 +82,7 @@ Adding a target does not add a branch to the renderer or Session Controller. The | `client/ui-session` | Session scope, standard sources, `SessionProvider`, and pending-interaction aggregation | Session transport, Conversation assembly, Approval/Question results | | `client/ui-workspace` | Workspace hook, browser UI, and cross-Controller navigation policy | Workspace transport, copies of Session data | | `client/ui-conversation` | Conversation core, registries, bindings, shell, input, composer, queue, and View navigation | Session transport, Chat/Trajectory snapshots | -| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, locale, and historical images | Session lifecycle, generic View navigation, Trajectory | +| `client/ui-chat` | Chat target, Node definitions, renderers, selection, details, and locale | Session lifecycle, generic View navigation, Trajectory, historical-image cache | | `client/ui-trajectory` | Trajectory target, event-record projection, and inspection view | Session snapshots, Chat snapshots | | `client/ui-approval` | Pending Approval, Remote listener, composer, and approval UI | Session control, generic composer election | | `client/ui-user-questions` | Pending Question, Remote listener, composer, and question UI | Session control, generic composer election | @@ -296,13 +296,13 @@ Draft and input state belong to Conversation UI and do not enter the Session sna ### Chat owner -`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, Tool-inspection collaboration, and historical-image cache. +`client/ui-chat` registers target id `chat` and owns the Chat snapshot builder, Conversation Node definitions, keyed node renderers, selection, details, statistics, locale, and Tool-inspection collaboration. It registers the `chat` target source through `ctx.uiSession.provide()`. `ChatNodeSeat` and internal Chat consumers use `useChat` instead of passing `useConversation(snapshot => snapshot.views.get('chat'))`. Only visible non-command Chat Nodes activate Chat. Ordinary command-only history keeps the Hero visible; the `/goal` `command-input` Node activates a fresh Conversation. -The historical-image cache's Session key, pending promise, generation guard, blob URL, and disposer all belong to `ui-chat`; draft images remain part of Conversation input. +The historical-image cache moved to `ui-conversation` (`ctx.uiConversation.imageUrl`), so Chat and Trajectory share one authorized read and one browser URL per session attachment ([Trajectory durable image attachments](../feature/2026-08-24-trajectory-image-attachments.md)); draft images remain part of Conversation input. ### Trajectory owner diff --git a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md index a007a42b3d..f0d9861eea 100644 --- a/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-20-client-session-conversation-ownership.zh.md @@ -82,7 +82,7 @@ UI 层可以同时读取多个 Controller 做一次导航决定,但不得把 | `client/ui-session` | Session scope、标准 source、`SessionProvider`、pending interaction 聚合 | Session transport、Conversation 组装、Approval/Question 结果 | | `client/ui-workspace` | Workspace hook、浏览器 UI 和跨 Controller 导航策略 | Workspace transport、Session 数据副本 | | `client/ui-conversation` | Conversation core、registry、binding、shell、input、composer、queue 和 View 导航 | Session transport、Chat/Trajectory snapshot | -| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details、locale 和历史图片 | Session 生命周期、通用 View 导航、Trajectory | +| `client/ui-chat` | Chat target、Node definitions、renderer、selection、details 和 locale | Session 生命周期、通用 View 导航、Trajectory、历史图片 cache | | `client/ui-trajectory` | Trajectory target、事件记录投影和检查视图 | Session snapshot、Chat snapshot | | `client/ui-approval` | Pending Approval、Remote listener、composer 和审批 UI | Session control、通用 composer election | | `client/ui-user-questions` | Pending Question、Remote listener、composer 和问题 UI | Session control、通用 composer election | @@ -296,13 +296,13 @@ Draft 与输入状态属于 Conversation UI,不进入 Session snapshot。Queue ### Chat owner -`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale、tool inspection 协作和历史图片 cache。 +`client/ui-chat` 注册 target id `chat`,并拥有 Chat snapshot builder、Conversation Node definitions、keyed node renderers、selection、details、stats、locale 和 tool inspection 协作。 它通过 `ctx.uiSession.provide()` 注册 `chat` target source。`ChatNodeSeat` 和 Chat 内部消费者使用 `useChat`,不再传递 `useConversation(snapshot => snapshot.views.get('chat'))`。 Chat activity 只由可见且非 command 的 Chat Node 激活。普通 command-only history 保持 Hero,`/goal` 的 `command-input` Node 激活 fresh Conversation。 -历史图片 cache 的 Session key、pending promise、generation guard、blob URL 和 disposer 同属 `ui-chat`;Draft 图片仍属于 Conversation input。 +历史图片 cache 已移入 `ui-conversation`(`ctx.uiConversation.imageUrl`),Chat 与 Trajectory 对同一会话附件共享一次授权读取和一个浏览器 URL([Trajectory 持久化图片附件](../feature/2026-08-24-trajectory-image-attachments.zh.md));Draft 图片仍属于 Conversation input。 ### Trajectory owner diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml index add950844a..e02c19ea8c 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md -2026-08-23-python-sdk-dsh-profile-runtime.md: dcb4f77048d516f0e187b611dc09c224fba47b58 -2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 505b49506ae4c4be4809d588448b2c593d3ebaf5 +2026-08-23-python-sdk-dsh-profile-runtime.md: 4af7812db6818b65c754a43ec1a7f973d1cbcbf9 +2026-08-23-python-sdk-dsh-profile-runtime.zh.md: 155e7d2ae0b0ba3e4163dd85a31de90bef9d588a diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md index dcb4f77048..4af7812db6 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.md @@ -24,7 +24,7 @@ Every Python launch requires either explicit `dsh_home` or a non-empty `DSH_HOME Persistent SDK customization uses the same profile interfaces as direct CLI use. `dsh plugin --profile ...` manages external dependencies and bundle order, `$DSH_HOME/profiles//cordis.patch.yml` owns persistent row changes, the home patch applies machine-local changes across profiles, and Python `patches` supplies invocation-specific overlays. A selected profile is valid only when it retains an SDK server row. Missing profiles, bundles, server rows, and invalid patches fail without a complete-config fallback; a profile that remains alive without serving JSON-RPC fails the independently bounded initialization handshake with a diagnostic naming that profile. -The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition rather than a server filter; dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. +The [standalone sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.md) lists one repository-owned bundle that inserts its complete explicit tree without `dsh-base`. Its persistent Bash and string-replace editor are present by composition; the shared JSON-RPC server exposes no root-agent tool filter. Dynamic runtime context, workspace instructions, settings, managed credentials, telemetry, compaction, and every other base row are absent. The same runtime still packages the full `sdk` and `web` profiles as separate choices. The runtime wheel installs a `dsh` console command. Ordinary profile and SDK execution remains Node-free; external package management requires a caller-installed `pnpm`. diff --git a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md index 505b49506a..155e7d2ae0 100644 --- a/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-23-python-sdk-dsh-profile-runtime.zh.md @@ -24,7 +24,7 @@ Python SDK 分发一个私有 Node 应用,直接启动完整外部 `cordis.yml 持久 SDK 自定义使用与直接 CLI 相同的 profile 接口。`dsh plugin --profile ...` 管理外部依赖与 bundle 顺序,`$DSH_HOME/profiles//cordis.patch.yml` 负责持久配置项变更,home patch 对所有 profile 应用机器本地变更,Python `patches` 则提供单次启动 overlay。所选 profile 只有保留 SDK server 配置项时才有效。缺失 profile、bundle、server 配置项或非法 patch 都会直接失败,不存在完整配置回退;保持运行却不提供 JSON-RPC 服务的 profile 会在独立有界的初始化握手中失败,诊断会指明该 profile。 -[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在,而不是通过 server 筛选;动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 +[独立 sdk-minimal profile](2026-08-24-standalone-sdk-minimal-profile.zh.md)只列出一个仓库自有组合包,该组合包会插入不含 `dsh-base` 的完整显式配置树。持久 Bash 与字符串替换 editor 通过组合存在;共享 JSON-RPC server 不暴露根 agent 工具筛选器。动态运行时上下文、workspace 指令、settings、托管凭据、遥测、compaction 与其他所有 base 配置项均不存在。同一运行时仍会把完整 `sdk` 与 `web` profile 作为独立选择打包。 运行时 wheel 安装 `dsh` 控制台命令。普通 profile 与 SDK 运行仍不需要 Node;外部包管理要求调用方自行安装 `pnpm`。 diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml index 0d7ca581c3..d0fe7a108e 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md -2026-08-24-standalone-sdk-minimal-profile.md: 692bf9763f4ad710ff5cc819a7480b2f3e8d9b5f -2026-08-24-standalone-sdk-minimal-profile.zh.md: f39baad1b4429b73f13d601716dadd8376c71bfb +2026-08-24-standalone-sdk-minimal-profile.md: 9c8afbaaf1a8e522af119c1d42ca5ad714eaa879 +2026-08-24-standalone-sdk-minimal-profile.zh.md: b1cd2a348c4b77f197e30a658c13691b2f35d26e diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md index 692bf9763f..9c8afbaaf1 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.md @@ -40,11 +40,11 @@ It also supersedes the minimal-overlay realization in [Python SDK runtime throug ## Verification -The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless Python example test boots the real `dsh --profile sdk-minimal` process and asserts the generated manifest, complete system prompt, and two advertised tools. The installed-wheel minimal scenario exercises persistent shell state, editor effects, JSONL persistence, and the committed model-visible snapshot through the packaged executable. +The bundle test pins the exact row and dependency roster. Profile-template and config-dump tests pin the one-bundle manifest, startup-only lifecycle, absence of `dsh-base`, and absence of module HMR. The keyless source test boots the real `dsh --profile sdk-minimal` process, completes a turn, and asserts the generated manifest. The installed-wheel minimal scenario owns the complete system prompt and two advertised tools in its committed model-visible snapshot while exercising persistent shell state, editor effects, and JSONL persistence through the packaged executable. ## Alternatives considered -**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also makes the minimal application depend on controls in shared SDK server and system-prompt interfaces. +**Keep the minimal mode as an overlay on `sdk`.** Rejected because filtering model-visible tools does not remove base services, prompt contributors, persistence choices, or later runtime behavior. It also required root-tool filtering in the shared SDK server and a complete-persona shortcut in the system-prompt config; neither shared interface carries those composition controls. **Restore a Python `cordis` argument or environment-selected complete config.** Rejected because it recreates a Python-owned application composition and bypasses profile plugin management and launcher lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md index f39baad1b4..b1cd2a348c 100644 --- a/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-24-standalone-sdk-minimal-profile.zh.md @@ -40,11 +40,11 @@ Python 运行时继续打包 `dsh-web-app` 与前端产物。`dsh web` 会从已 ## 验证 -组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless Python 示例测试启动真实 `dsh --profile sdk-minimal` 进程,并断言生成的 manifest、完整系统提示词与两个对外公布的工具。Installed-wheel 极简场景通过打包可执行程序验证持久 shell 状态、editor 文件效果、JSONL 持久化与已提交的模型可见快照。 +组合包测试固定确切配置项与依赖清单。Profile 模板与配置 dump 测试固定单组合包 manifest、仅启动时生命周期、`dsh-base` 缺席与模块 HMR 缺席。Keyless 源码测试启动真实 `dsh --profile sdk-minimal` 进程、完成一个回合并断言生成的 manifest。Installed-wheel 极简场景通过已提交的模型可见快照固定完整系统提示词和两个对外公布的工具,同时经由打包可执行程序验证持久 shell 状态、editor 文件效果与 JSONL 持久化。 ## 考虑过的替代方案 -**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为,还会让极简应用依赖共享 SDK server 与系统提示词接口中的控制项。 +**继续把极简模式作为 `sdk` 上的 overlay。** 否决:筛选面向模型的工具不会移除 base 服务、提示词贡献方、持久化选择或后续运行时行为。该方案还要求共享 SDK server 提供根工具筛选,并要求 system-prompt 配置提供 complete-persona 快捷项;这两个共享接口均不再携带这些组合控制项。 **恢复 Python `cordis` 参数或由环境选择的完整配置。** 否决:这会重新创建 Python 自有应用组合,并绕过 profile 插件管理与 launcher 生命周期。 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml index c24a66064d..575a930f99 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.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-11-minimal-profiles-bare-two-tool-runtime.md -2026-08-11-minimal-profiles-bare-two-tool-runtime.md: d201a7557136822d82950466e39ae0efd27a5888 -2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: b56f5cea26bb9305ad75a10d153b64f11f841e96 +2026-08-11-minimal-profiles-bare-two-tool-runtime.md: ffe8e799e1ba5a71939e3a17d484afd209b4014f +2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md: 587ba696382575244a75f23fe69c14252caf7544 diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md index d201a75571..ffe8e799e1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.md @@ -22,7 +22,7 @@ The standalone [`@deepseek-ai/dsh-sdk-minimal` bundle](../../../../packages/bund The Web replay boots the complete Web host, creates the agent through the preset service, and asserts that the scoped filesystem is bare, no scoped compaction service exists, no system-prompt-owned runtime-context message was appended, and the assembled request contains exactly the fixed prompt and two tools. It then executes persistent Bash and the editor against the real scoped services. -The SDK keyless process test boots real `dsh --profile sdk-minimal`, injects an environment-selected prompt, and asserts the generated one-bundle manifest, assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Python SDK bundled-runtime coverage initializes the standalone profile through each available packaged carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. +The SDK keyless source test boots real `dsh --profile sdk-minimal`, completes a turn with an environment-selected prompt, and asserts the generated one-bundle manifest. The Python SDK bundled-runtime snapshot owns the assembled prompt, exact two-tool catalog, and absence of every system-prompt-owned runtime-context message. Packaged-runtime coverage initializes the standalone profile through each available carrier with environment-selected model, model capacity, and prompt values, then executes the selected persistent shell and editor. Cordis validation checks that both configurations resolve their declared plugins and configuration fields. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md index b56f5cea26..587ba69638 100644 --- a/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md +++ b/.agents/notes/implemented/feature/2026-08-11-minimal-profiles-bare-two-tool-runtime.zh.md @@ -22,7 +22,7 @@ Web `minimal` preset 与独立 JSON-RPC minimal 组合对外提供持久 `bash` Web 回放会启动完整 Web 宿主,通过 preset 服务创建 agent,并断言作用域文件系统为裸后端、不存在作用域压缩服务、没有追加 system-prompt 拥有的 runtime-context 消息,而且组装请求只包含固定提示词与两个工具。随后,它通过真实作用域服务执行持久 Bash 和编辑器。 -SDK keyless 进程测试启动真实 `dsh --profile sdk-minimal`,注入由环境选择的提示词,并断言生成的单组合包 manifest、组装提示词、精确双工具目录,以及不存在任何 system-prompt 拥有的 runtime-context 消息。Python SDK 内置运行时覆盖会通过每种可用的打包载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 +SDK keyless 源码测试启动真实 `dsh --profile sdk-minimal`,使用环境选择的提示词完成一个回合,并断言生成的单组合包 manifest。Python SDK 打包运行时快照固定组装提示词、精确双工具目录,并固定不存在任何 system-prompt 所拥有的 runtime-context 消息。打包运行时覆盖会通过每种可用载体,使用环境选择的模型、模型容量和提示词值初始化独立 profile,然后执行所选持久 shell 与 editor。Cordis 校验会检查两份配置能否解析声明的插件和配置字段。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.i18n.yaml new file mode 100644 index 0000000000..6b70d9c8f6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-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-08-24-trajectory-image-attachments.md +2026-08-24-trajectory-image-attachments.md: 6f89840a7d4686e45012ea2ae25f4cc939a5ca0c +2026-08-24-trajectory-image-attachments.zh.md: f1e8634639e42adc094021c03a339b8f8223bfff diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md new file mode 100644 index 0000000000..6f89840a7d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory durable image attachments + +Status: implemented + +English | [中文](2026-08-24-trajectory-image-attachments.zh.md) + +## Problem + +Trajectory did not display session images. A durable `{ type: 'image', attachment: ImageAttachmentRef }` block rendered as pretty-printed JSON in the details panel, and an image-only user message produced an empty ledger row. The only image path Trajectory knew was `imageSrc` sniffing over inline wire fields (`url`, `image_url`, base64 `data`), which no production event carries: every producer commits a durable `ImageAttachmentRef` before its event is appended. Users could not confirm from the execution ledger which image the model saw ([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)), while Chat already displayed the same attachments. + +## Decision + +- `ui-conversation` owns the per-session durable image URL cache. `HistoricalImageCache` moved from `ui-chat` into `packages/client/ui-conversation/src/client/conversation/historical-images.ts` and is served as `ctx.uiConversation.imageUrl(sessionId, attachment)`. Chat and Trajectory resolve through the same instance, so one session attachment costs one `session.attachment` read and one browser URL, revoked when the Session binding is released. This partially supersedes the `ui-chat` cache ownership recorded in [client Session/Conversation ownership](../architecture/2026-08-20-client-session-conversation-ownership.md). +- The gallery owner contract (`MessageImagesOwnerProps`, `RenderMessageImages`) moved to the `ui-conversation` client contract. `ui-chat` keeps its `conversation.message.images` SlotMap row over the shared owner type; `ui-trajectory` declares its own child slot `conversation.trajectory.images` with the same owner type; `ui-attachment` registers the one `MessageImages` gallery component into both keys, so loading, retry, and lightbox behavior is identical in both views. +- `TrajectorySourceBlock` carries `attachment?: ImageAttachmentRef` instead of `imageSrc`/`imageAlt`. The inline-source sniffing (`sourceImage`, `safeImageSource`) and the Trajectory-local `PanelImage` renderer are removed: no producer writes inline image bytes or URLs into the session log, so those paths were dead code, and the issue explicitly excludes upload-time transient paths. +- A record whose content has images but no text labels its ledger row with the locale-owned `layout.imageOnly` count; tool results with only images use the same label for their result summary instead of a JSON dump. +- Neither the storage nor the BFF changes: `session.attachment` already authorizes by session-log reference (missing, corrupt, and unreferenced attachments fail loud into the gallery's retry state), and sha256 content addressing already stores each image once. + +## Alternatives considered + +**Keep Trajectory's own `` rendering and feed it resolved URLs.** This duplicates the loading placeholder, retry control, and lightbox that `ui-attachment` already owns, and contradicts [slot-based attachment ownership](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.md), which rejected cross-plugin component imports. + +**Lift the `conversation.message.images` declaration to a shared parent so both views render one key.** `renderSlot` is typed to the declaring entry's own children table, so a sibling `conversation.view` entry cannot render another entry's child key; the slot registry also rejects a second declaration of the same key. A second key sharing the owner type is the supported composition and lets a theme replace either gallery independently. + +**Keep the inline `imageSrc` sniffing beside the durable path.** All producers (host prompt admission, `read_image`, MCP projection, ACP ingress) commit durable refs before their events append, so the sniffing matched nothing; keeping it would preserve a non-durable rendering path the acceptance criteria exclude. + +**A Trajectory-owned image cache.** A second cache per view issues duplicate `session.attachment` RPCs and duplicate blob URLs for the same session attachment, violating the "Chat and Trajectory reference the same session attachment" requirement for no benefit. + +## Consequences + +- Both views present one gallery implementation, so image behavior (sizing, retry, lightbox, labels) cannot drift between Chat and Trajectory, and a session attachment is read once regardless of how many views show it. +- `TrajectoryTable` threads a required `renderImages` prop through its detail components; `ui-trajectory` gains a type-only dependency on `dsh-attachment`, and `ui-attachment` gains a type-only dependency on `ui-trajectory` for the new SlotMap row. +- The keyless assembled snapshot `apps/web/tests/trajectory-image-display.snapshot.ts` pins the shared-cache fact directly: the details-panel image URL is string-identical to the Chat gallery's URL for the same fixture attachment. diff --git a/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md new file mode 100644 index 0000000000..f1e8634639 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-24-trajectory-image-attachments.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Trajectory 持久化图片附件 + +Status: implemented + +[English](2026-08-24-trajectory-image-attachments.md) | 中文 + +## Problem + +Trajectory 不展示会话图片。持久化的 `{ type: 'image', attachment: ImageAttachmentRef }` 块在详情面板里渲染成格式化 JSON,纯图片的用户消息在记录表中是一个空行。Trajectory 唯一认识的图片路径是对内联 wire 字段(`url`、`image_url`、base64 `data`)的 `imageSrc` 嗅探,而生产事件从不携带这些字段:每个生产方都在事件追加前提交持久化的 `ImageAttachmentRef`。用户无法从执行记录确认模型看到了哪张图([issue #2986](https://github.com/deepseek-harness/deepseek-harness/issues/2986)),而 Chat 已经能展示同样的附件。 + +## Decision + +- `ui-conversation` 拥有按会话的持久化图片 URL 缓存。`HistoricalImageCache` 从 `ui-chat` 移入 `packages/client/ui-conversation/src/client/conversation/historical-images.ts`,以 `ctx.uiConversation.imageUrl(sessionId, attachment)` 提供。Chat 与 Trajectory 通过同一实例解析,因此一个会话附件只产生一次 `session.attachment` 读取和一个浏览器 URL,并随 Session binding 释放而撤销。这部分取代了 [client Session/Conversation 所有权](../architecture/2026-08-20-client-session-conversation-ownership.zh.md)中记录的 `ui-chat` 缓存归属。 +- 画廊 owner 契约(`MessageImagesOwnerProps`、`RenderMessageImages`)移入 `ui-conversation` 客户端契约。`ui-chat` 的 `conversation.message.images` SlotMap 行沿用共享 owner 类型;`ui-trajectory` 以同一 owner 类型声明自己的子槽位 `conversation.trajectory.images`;`ui-attachment` 把同一个 `MessageImages` 画廊组件注册进两个键,因此加载、重试与灯箱行为在两个视图中完全一致。 +- `TrajectorySourceBlock` 以 `attachment?: ImageAttachmentRef` 取代 `imageSrc`/`imageAlt`。内联来源嗅探(`sourceImage`、`safeImageSource`)与 Trajectory 本地的 `PanelImage` 渲染器一并删除:没有生产方向会话日志写入内联图片字节或 URL,这些路径是死代码,且 issue 明确排除上传来源的临时路径。 +- 内容含图片但没有文本的记录,其记录表行以 locale 持有的 `layout.imageOnly` 计数标注;只含图片的工具结果的摘要也使用同一标签,而不是 JSON 转储。 +- 存储与 BFF 均不改动:`session.attachment` 已按会话日志引用授权(缺失、损坏与未被引用的附件显式失败并进入画廊的重试态),sha256 内容寻址已保证每张图片只存一份。 + +## Alternatives considered + +**保留 Trajectory 自己的 `` 渲染并喂给它解析好的 URL。** 这会重复 `ui-attachment` 已拥有的加载占位、重试控件和灯箱,并与[基于 slot 的附件所有权](../architecture/2026-08-17-dynamic-client-render-and-attachment-ownership.zh.md)相抵触,该决定已拒绝跨插件直接 import 组件。 + +**把 `conversation.message.images` 的声明上提到共享父级,让两个视图渲染同一个键。** `renderSlot` 的类型限定在声明入口自己的 children 表内,同级的 `conversation.view` 入口无法渲染另一个入口的子键;slot registry 也拒绝对同一键的第二次声明。共享 owner 类型的第二个键是受支持的组合方式,且允许主题独立替换任一画廊。 + +**在持久化路径之外保留内联 `imageSrc` 嗅探。** 所有生产方(宿主 prompt admission、`read_image`、MCP 投影、ACP 入口)都在事件追加前提交持久化引用,嗅探不会命中任何东西;保留它等于保留验收标准明确排除的非持久化渲染路径。 + +**Trajectory 自有的图片缓存。** 每个视图一份缓存会对同一会话附件发出重复的 `session.attachment` RPC 和重复的 blob URL,违背"Chat 与 Trajectory 引用同一会话附件"的要求,且没有任何收益。 + +## Consequences + +- 两个视图共用一个画廊实现,图片行为(尺寸、重试、灯箱、文案)不会在 Chat 与 Trajectory 之间漂移,且无论多少个视图展示,一个会话附件只读取一次。 +- `TrajectoryTable` 需要把必填的 `renderImages` prop 逐层传入详情组件;`ui-trajectory` 新增对 `dsh-attachment` 的仅类型依赖,`ui-attachment` 为新的 SlotMap 行新增对 `ui-trajectory` 的仅类型依赖。 +- keyless 组装快照 `apps/web/tests/trajectory-image-display.snapshot.ts` 直接钉住共享缓存这一事实:详情面板中的图片 URL 与 Chat 画廊对同一 fixture 附件的 URL 字符串相同。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 96e7b4c7b0..8af7762497 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: 33de399dc4b2b8e67ed24ed046fcc0da2ff0a7ac -README.zh.md: 0f20245f318e31159780d29cca955fc85a5b5481 +README.md: eb33816b3ac859e8173e62635f74abbe13fb8462 +README.zh.md: ed84927aa4f211926fd32750385268dc5153b3a9 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 33de399dc4..eb33816b3a 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -39,7 +39,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, relative plugin names in inserted rows resolve beside their patch file, and unmatched patch targets are reported on stderr. A dump initializes missing profile files but does not prepare the runtime module fallback under `$DSH_HOME/profiles/node_modules`. It never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 0f20245f31..ed84927aa4 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -39,7 +39,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,插入行中的相对插件名以各自 patch 文件所在目录解析,找不到目标的 patch 会报告到 stderr。dump 操作会初始化缺失的 profile 文件,但不会准备 `$DSH_HOME/profiles/node_modules` 下的运行时模块 fallback。它不会运行应用的命令行参数提供方,因此展示的是解析任何应用参数之前的组合配置树;如果调用中包含应用参数,dump 会拒绝该调用。 ## 插件管理 diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 9e7c600c8d..321849f2d9 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -41,7 +41,7 @@ switch (invocation.mode) { } case 'dump-config': { const { runDumpConfig } = await import('./dump-config.ts') - await runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) + runDumpConfig(invocation.profile, invocation.defaultOnly, invocation.patches) break } default: diff --git a/apps/cli/src/dump-config.ts b/apps/cli/src/dump-config.ts index dc2089e7c8..1754eb4efd 100644 --- a/apps/cli/src/dump-config.ts +++ b/apps/cli/src/dump-config.ts @@ -26,10 +26,9 @@ const NAME = 'dsh' * (the recovery diagnostic for a broken `cordis.patch.yml`, which is then * never parsed). * @param patches - `--patch` overlay paths, in argv order. - * @returns settlement after the profile is healed and the dump is written. */ -export async function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): Promise { - const loaded = await prepareProfile(profile, !defaultOnly) +export function runDumpConfig(profile: string, defaultOnly: boolean, patches: readonly string[]): void { + const loaded = prepareProfile(profile, !defaultOnly) const layers: ConfigDumpLayer[] = loaded.layers.map(layer => ({ label: layer.packageName, patches: layer.patches, diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index 30058a380f..674095bca7 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -103,20 +103,19 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b } /** - * Load a resolved profile for `name`: heal the shared module fallback, then - * (re)write the empty root config. The root is always rewritten: the whole - * composition is patch layers, and the vendored Loader's tree write-back (a - * plugin self-disposing persists the current tree) can bake composed rows - * into this file — which would duplicate every bundle insert on the next - * boot. The file exists on disk only because the Loader needs a real include - * root to anchor `baseUrl` at the profile directory (the config dump anchors - * on the same file, so both compose over the identical base). + * Load a resolved profile for `name` and (re)write the empty root config. The + * root is always rewritten: the whole composition is patch layers, and the + * vendored Loader's tree write-back (a plugin self-disposing persists the + * current tree) can bake composed rows into this file — which would duplicate + * every bundle insert on the next boot. The file exists on disk only because + * the Loader needs a real include root to anchor `baseUrl` at the profile + * directory (the config dump anchors on the same file, so both compose over + * the identical base). * @param name - the profile name. * @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump). * @returns the loaded profile. */ -export async function prepareProfile(name: string, userLayer = true): Promise { - await healProfilesModuleFallback(INSTALL_ANCHOR) +export function prepareProfile(name: string, userLayer = true): Profile { const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer }) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG) return profile @@ -158,7 +157,8 @@ async function composeProfile( name: string, patchFiles: readonly string[], ): Promise { - const profile = await prepareProfile(name) + await healProfilesModuleFallback(INSTALL_ANCHOR) + const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index eb22fed232..5f9791a050 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -911,6 +911,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain('agents: []') expect(stdout).toContain('# == @deepseek-ai/dsh-base') expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") + expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false) }, 30_000) it('prints the headless profile without Host or browser layers', async () => { diff --git a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts index 96cd67172d..a2e2b57829 100644 --- a/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts +++ b/apps/cli/tests/profiles/sdk/keyless-smoke.e2e.ts @@ -169,15 +169,11 @@ describe('Python SDK dsh profile keyless smoke', () => { } }, 40_000) - it('boots the standalone minimal profile with its exact model-facing roster', async () => { + it('boots the standalone minimal profile through its generated manifest', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-python-sdk-minimal-')) - const modelRequests: Record[] = [] const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) + request.resume() request.on('end', () => { - modelRequests.push(JSON.parse(body) as Record) response.writeHead(200, { 'content-type': 'text/event-stream' }) response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') @@ -237,13 +233,6 @@ describe('Python SDK dsh profile keyless smoke', () => { return params?.sessionId === 'minimal' && event?.type === 'turn/end' }, () => stderr) - const request = modelRequests[0] as { - messages?: Array<{ role?: string; content?: unknown }> - tools?: Array<{ function?: { name?: string } }> - } - expect(request.messages?.[0]).toMatchObject({ role: 'system', content: 'Minimal allowlist prompt.' }) - const shellTool = process.platform === 'win32' ? 'pwsh' : 'bash' - expect(request.tools?.map(tool => tool.function?.name).sort()).toEqual([shellTool, 'str_replace_editor'].sort()) const profile = JSON.parse( await readFile(join(root, '.dsh', 'profiles', 'sdk-minimal', 'package.json'), 'utf8'), ) as { dsh?: { profile?: { bundles?: string[]; patchReload?: string } } } diff --git a/apps/web/tests/trajectory-image-display.expected.e2e.ts b/apps/web/tests/trajectory-image-display.expected.e2e.ts new file mode 100644 index 0000000000..90659cf2b5 --- /dev/null +++ b/apps/web/tests/trajectory-image-display.expected.e2e.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom +// Trajectory 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 73 carries an image in BOTH a +// user message and an assistant message, and pins the Trajectory surfaces: +// selecting the ledger record renders the shared ui-attachment gallery from +// the durable session-log reference, and the browser URL is the SAME object +// URL Chat resolved — one sessions.attachment read per session attachment. +import { fireEvent, screen, waitFor, within } from '@testing-library/react' +import { expect, it, vi } from 'vitest' +import { installAssembledBootEnv, mountAssembledApp } from './assembled-boot.ts' + +installAssembledBootEnv() + +/** Open the fixture history session and wait for the Chat gallery to load. */ +async function openFixtureSession(): Promise { + const tree = await screen.findByRole('tree', { name: 'Sessions' }, { 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 }) +} + +/** Scroll the virtual ledger until the row whose text contains `needle` mounts. */ +async function scrollRowIntoWindow(needle: string): Promise { + await waitFor(() => { + if (document.querySelectorAll('tr[data-trajectory-row-key]').length === 0) { + throw new Error('trajectory rows not mounted') + } + }, { timeout: 10_000 }) + const pane = document.querySelector('[data-trajectory-scroll] table')?.parentElement + if (!(pane instanceof HTMLElement)) throw new Error('trajectory scroll pane missing') + for (let top = 0; top <= 40_000; top += 1_000) { + pane.scrollTop = top + fireEvent.scroll(pane) + // Let the virtualizer publish the new window before probing. + await new Promise(resolve => setTimeout(resolve, 25)) + const hit = [...document.querySelectorAll('tr[data-trajectory-row-key]')] + .find(row => row.textContent?.includes(needle)) + if (hit !== undefined) return hit + } + throw new Error(`trajectory row containing ${JSON.stringify(needle)} never mounted`) +} + +it('renders durable record images in the Trajectory details panel from the shared cache', async () => { + // The virtual ledger needs a measurable viewport; jsdom reports zero + // heights, so pin one and neutralize the imperative tail scroll. + vi.spyOn(HTMLElement.prototype, 'offsetHeight', 'get').mockReturnValue(600) + Object.defineProperty(HTMLElement.prototype, 'scrollTo', { + configurable: true, + value: () => {}, + }) + mountAssembledApp() + await openFixtureSession() + const chatSrc = document.querySelector('[data-align="end"] img')?.getAttribute('src') + if (chatSrc === null || chatSrc === undefined) throw new Error('chat gallery image missing') + + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + const userRow = await scrollRowIntoWindow('历史用户图片') + fireEvent.click(userRow) + + // Selecting the record opens the details panel; the ui-attachment gallery + // resolves the durable reference through the SAME per-session cache Chat + // used, so the object URL is identical — no second attachment read. + const panel = await screen.findByRole('tabpanel') + await waitFor(() => { + expect(within(panel).getAllByRole('img').length).toBeGreaterThan(0) + }, { timeout: 10_000 }) + expect(within(panel).getAllByRole('img').map(img => ({ + alt: img.getAttribute('alt'), + scheme: img.getAttribute('src')?.split(':')[0], + sharedWithChat: img.getAttribute('src') === chatSrc, + }))).toMatchInlineSnapshot(` + [ + { + "alt": "fixture-image.png", + "scheme": "blob", + "sharedWithChat": true, + }, + ] + `) +}) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 300fbbdcf0..1ca2c9ffa2 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: dd8231c9bc259e99128ee4a9498c99fa4295b884 -config-catalog.zh.md: 080071eca5c1f10c398d833e93d2847a5fbe2426 +config-catalog.md: e4c43c305f22b2ab1713626c0a6a6bde6c4d8e1b +config-catalog.zh.md: 6f8afcd8aca2e675a3bfa82ea0ec205d6046a1fb diff --git a/docs/config-catalog.md b/docs/config-catalog.md index dd8231c9bc..e4c43c305f 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -166,9 +166,9 @@ Source: [`packages/preset/agent-presets/src/preset.ts:52`](../packages/preset/ag * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -197,8 +197,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -1723,13 +1721,6 @@ Requires: `agents` export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -2466,8 +2457,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 080071eca5..6f8afcd8ac 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -168,9 +168,9 @@ export type PresetTrust = 'system' | 'user' * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -199,8 +199,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -1725,13 +1723,6 @@ export interface Config { export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -2468,8 +2459,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 452f44877a..00ddcc3c74 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: 985636d3889394b912dcc1d68cb9e0a4fc1cb11b -module-graph.zh.md: d352643e21e8d54e523885ea06320610e6951dfe +module-graph.md: 1cca77ee09ce2a47e732893d814531daac3c07ad +module-graph.zh.md: 46390d0c897f55c79bb183ea56deb2843fd80b94 diff --git a/docs/module-graph.md b/docs/module-graph.md index 985636d388..1cca77ee09 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1454,6 +1454,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1494,7 +1495,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1541,6 +1541,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1876,15 +1877,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index d352643e21..46390d0c89 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -1456,6 +1456,7 @@ flowchart TD pkg_client_ui_settings_general --> pkg_settings pkg_client_ui_trajectory --> pkg_agent pkg_client_ui_trajectory --> pkg_api_session_controller + pkg_client_ui_trajectory --> pkg_attachment pkg_client_ui_trajectory --> pkg_client_locale pkg_client_ui_trajectory --> pkg_client_ui_conversation pkg_client_ui_trajectory --> pkg_client_ui_renderer @@ -1496,7 +1497,6 @@ flowchart TD pkg_client_ui_chat --> pkg_session_stats pkg_client_ui_chat --> pkg_token_meter pkg_client_ui_chat --> pkg_tools - pkg_client_ui_chat --> pkg_util_crypto pkg_client_ui_chat --> pkg_util_workspace_path pkg_client_ui_commands --> pkg_api_remotes pkg_client_ui_commands --> pkg_api_session_controller @@ -1543,6 +1543,7 @@ flowchart TD pkg_client_ui_attachment --> pkg_client_ui_chat pkg_client_ui_attachment --> pkg_client_ui_conversation pkg_client_ui_attachment --> pkg_client_ui_renderer + pkg_client_ui_attachment --> pkg_client_ui_trajectory pkg_client_ui_attachment --> pkg_invariants pkg_client_ui_deliverables --> pkg_client_connection pkg_client_ui_deliverables --> pkg_client_locale @@ -1878,15 +1879,15 @@ flowchart TD | [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session) | | [`client-ui-settings-general`](../packages/client/ui-settings-general) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-settings`](../packages/client/ui-settings), [`client-ui-sidebar`](../packages/client/ui-sidebar), [`invariants`](../packages/runtime-diagnostics/invariants), [`settings`](../packages/settings/settings) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`client-ui-user-questions`](../packages/client/ui-user-questions) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) | -| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-crypto`](../packages/util/crypto), [`util-workspace-path`](../packages/util/workspace-path) | +| [`client-ui-chat`](../packages/client/ui-chat) | `client` | [`agent`](../packages/core/agent), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`api-workspace-controller`](../packages/api/workspace-controller), [`attachment`](../packages/attachment/attachment), [`client-locale`](../packages/client/locale), [`client-ui-approval`](../packages/client/ui-approval), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-layout`](../packages/client/ui-layout), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-workspace`](../packages/client/ui-workspace), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-stats`](../packages/session/session-stats), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`util-workspace-path`](../packages/util/workspace-path) | | [`client-ui-commands`](../packages/client/ui-commands) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`client-ui-reference`](../packages/client/ui-reference) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`session-reference`](../packages/context/session-reference), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`api-session-controller`](../packages/api/session-controller), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-input-trigger`](../packages/client/ui-input-trigger), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`client-ui-directory-picker-browse`](../packages/client/ui-directory-picker-browse), [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native), [`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/runtime-diagnostics/invariants) | | [`session-log-export`](../packages/session-query/session-log-export) | `session-query` | [`client-locale`](../packages/client/locale), [`client-ui-commands`](../packages/client/ui-commands), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants) | -| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants) | +| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | [`attachment`](../packages/attachment/attachment), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-trajectory`](../packages/client/ui-trajectory), [`invariants`](../packages/runtime-diagnostics/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | | [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | [`api-remotes`](../packages/api/remotes), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-ui-chat`](../packages/client/ui-chat), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`invariants`](../packages/runtime-diagnostics/invariants), [`message-feedback`](../packages/feedback/message-feedback), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) | diff --git a/docs/subsystems/slots.i18n.yaml b/docs/subsystems/slots.i18n.yaml index ad6907574f..cda920ea03 100644 --- a/docs/subsystems/slots.i18n.yaml +++ b/docs/subsystems/slots.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/slots.md -slots.md: d201223c9e630f16f310d8ff90318ab8c43211e5 -slots.zh.md: 23277e94e2a9e85be7f1745a172dfd9cb8a5be37 +slots.md: 6eb61780ca2f06ebc38a0fcf2638a7fafcd5ceee +slots.zh.md: e5a25763d382c228525f3da24694e41dd09737e0 diff --git a/docs/subsystems/slots.md b/docs/subsystems/slots.md index d201223c9e..6eb61780ca 100644 --- a/docs/subsystems/slots.md +++ b/docs/subsystems/slots.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/docs/subsystems/slots.zh.md b/docs/subsystems/slots.zh.md index 23277e94e2..e5a25763d3 100644 --- a/docs/subsystems/slots.zh.md +++ b/docs/subsystems/slots.zh.md @@ -134,7 +134,8 @@ root │ │ │ ├─ conversation.chat.turnTail │ │ │ └─ tool.call.toolview │ │ │ └─ tool.view.cordis -│ │ └─ conversation.message.images +│ │ ├─ conversation.message.images +│ │ └─ conversation.trajectory.images │ ├─ conversation.session.header │ │ ├─ conversation.session.header.lineage │ │ ├─ conversation.session.header.actions diff --git a/packages/client/ui-attachment/README.i18n.yaml b/packages/client/ui-attachment/README.i18n.yaml index 4bbc3c3dcd..40aab703eb 100644 --- a/packages/client/ui-attachment/README.i18n.yaml +++ b/packages/client/ui-attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-attachment/README.md -README.md: 925187da23e9acec9a69959bfc29ce7ec62f0096 -README.zh.md: 2872dac19e36143923ba39e4b3efbd6e8bb27cac +README.md: ee9b604d0227b5ae52f833f97f80429e4ab93bea +README.zh.md: 8d8cd43c32587f258de3e6a26fa7e0d0127dbf57 diff --git a/packages/client/ui-attachment/README.md b/packages/client/ui-attachment/README.md index 925187da23..ee9b604d02 100644 --- a/packages/client/ui-attachment/README.md +++ b/packages/client/ui-attachment/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Dynamic attachment presentation plugin for the conversation UI. It waits for the conversation package's `conversation.input.attachments` and `conversation.message.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, chat-history image gallery, and original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. +Dynamic attachment presentation plugin for the conversation UI. It waits for the `conversation.input.attachments`, `conversation.message.images`, and `conversation.trajectory.images` declarations through `ctx.slots.inject`, then registers the composer draft-image rail, document drop target, the history image gallery serving both the Chat transcript and the Trajectory inspector, and the original-image lightbox. The conversation slot owner supplies attachment data, image loading, callbacks, and its namespace translator; presentation components remain pure props and are not exported from the package entry. ## Attachment rail diff --git a/packages/client/ui-attachment/README.zh.md b/packages/client/ui-attachment/README.zh.md index 2872dac19e..8d8cd43c32 100644 --- a/packages/client/ui-attachment/README.zh.md +++ b/packages/client/ui-attachment/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 conversation 包声明 `conversation.input.attachments` 与 `conversation.message.images`,随后注册输入框草稿图片栏、文档拖放目标、聊天历史图片画廊和原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 +对话 UI 的动态附件呈现插件。它通过 `ctx.slots.inject` 等待 `conversation.input.attachments`、`conversation.message.images` 与 `conversation.trajectory.images` 声明,随后注册输入框草稿图片栏、文档拖放目标、同时服务 Chat 会话记录与 Trajectory 检查器的历史图片画廊,以及原图灯箱。conversation slot 持有方提供附件数据、图片加载、回调及其命名空间翻译器;呈现组件保持纯 props,且不从包入口导出。 ## 附件栏 diff --git a/packages/client/ui-attachment/package.json b/packages/client/ui-attachment/package.json index 71ba3edf76..d5a10b446b 100644 --- a/packages/client/ui-attachment/package.json +++ b/packages/client/ui-attachment/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-client-ui-attachment", - "description": "Dynamic attachment presentation plugin for conversation input and message-image slots", + "description": "Dynamic attachment presentation plugin for conversation input, message-image, and trajectory image slots", "version": "0.1.1-rc.2", "publishConfig": { "access": "public" @@ -34,7 +34,8 @@ "inject": [ "@deepseek-ai/dsh-client-ui-chat", "@deepseek-ai/dsh-client-ui-conversation", - "@deepseek-ai/dsh-client-ui-renderer" + "@deepseek-ai/dsh-client-ui-renderer", + "@deepseek-ai/dsh-client-ui-trajectory" ], "platform": "web" } @@ -54,6 +55,7 @@ "@types/react-dom": "~18.3.0", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", @@ -72,6 +74,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^" } diff --git a/packages/client/ui-attachment/src/client/index.ts b/packages/client/ui-attachment/src/client/index.ts index bcdbfb7adf..8fe94f64ee 100644 --- a/packages/client/ui-attachment/src/client/index.ts +++ b/packages/client/ui-attachment/src/client/index.ts @@ -3,6 +3,7 @@ import type { Context as ClientContext } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' +import type {} from '@deepseek-ai/dsh-client-ui-trajectory/client' import { ComposerAttachments } from './ComposerAttachments.tsx' import { MessageImages } from './MessageImages.tsx' @@ -19,4 +20,8 @@ export function apply(ctx: ClientContext): void { name: 'conversation.message.images', locale: 'conversation', }, MessageImages)) + ctx.slots.inject('conversation.trajectory.images', () => ctx.slots.register({ + name: 'conversation.trajectory.images', + locale: 'conversation', + }, MessageImages)) } diff --git a/packages/client/ui-attachment/tests/plugin.client.spec.ts b/packages/client/ui-attachment/tests/plugin.client.spec.ts index 9ca742377f..21d84953de 100644 --- a/packages/client/ui-attachment/tests/plugin.client.spec.ts +++ b/packages/client/ui-attachment/tests/plugin.client.spec.ts @@ -14,6 +14,7 @@ async function bench() { children: { 'conversation.input.attachments': { kind: 'single', scope: 'session-maybe' }, 'conversation.message.images': { kind: 'single', scope: 'session' }, + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, }, } as never, () => null) const fiber = ctx.plugin({ inject: [...inject], apply }) @@ -26,7 +27,7 @@ describe('attachment plugin', () => { expect(() => { applyHost() }).not.toThrow() }) - it('registers both entries and removes them with the plugin fiber', async () => { + it('registers all entries and removes them with the plugin fiber', async () => { const { ctx, fiber } = await bench() expect(inject).toEqual(['slots']) expect(ctx.slots.entries('conversation.input.attachments')).toMatchObject([{ @@ -37,10 +38,15 @@ describe('attachment plugin', () => { locale: 'conversation', component: MessageImages, }]) + expect(ctx.slots.entries('conversation.trajectory.images')).toMatchObject([{ + locale: 'conversation', + component: MessageImages, + }]) await fiber.dispose() expect(ctx.slots.entries('conversation.input.attachments')).toHaveLength(0) expect(ctx.slots.entries('conversation.message.images')).toHaveLength(0) + expect(ctx.slots.entries('conversation.trajectory.images')).toHaveLength(0) }) }) diff --git a/packages/client/ui-attachment/tsconfig.json b/packages/client/ui-attachment/tsconfig.json index 0cd20ee366..b2783b20e9 100644 --- a/packages/client/ui-attachment/tsconfig.json +++ b/packages/client/ui-attachment/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../ui-conversation" }, + { + "path": "../ui-trajectory" + }, { "path": "../ui-slots" }, diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 4abebdd3f3..4859153e08 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 56bb20ab7b4d9b5c0c95142b311a07ad9b8a1fd3 -README.zh.md: 40ee1ee710e2f86e802ffaac4dc0bb10852f128f +README.md: 5253cb95b0e5c0b89c32646e2ae2915936d35288 +README.zh.md: 8cd2d0d581a0493892aed23f42ebc0c229a0bc17 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 56bb20ab7b..5253cb95b0 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, historical images, and scroll restoration. +The browser Chat target for Conversation assembly. It registers Chat event definitions and snapshot construction, supplies `useChat`, renders transcript nodes and details, and owns Chat-specific stores, actions, localization, and scroll restoration; historical image URLs resolve through the Conversation-owned per-session cache (`ctx.uiConversation.imageUrl`). ## Model Experience diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 40ee1ee710..8cd2d0d581 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化、历史图片与滚动位置恢复。 +Conversation 组装的浏览器 Chat target。本包注册 Chat event definition 与 snapshot 构造、提供 `useChat`、渲染 transcript node 和详情,并拥有 Chat 专属 store、action、本地化与滚动位置恢复;历史图片 URL 通过 Conversation 持有的按会话缓存(`ctx.uiConversation.imageUrl`)解析。 ## 模型体验 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index 186afe1f55..b29c675a08 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -72,7 +72,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^" }, "devDependencies": { @@ -101,7 +100,6 @@ "@deepseek-ai/dsh-session-stats": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-util-crypto": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", "@types/react": "~18.3.1", "react": "^18.2.0" diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 1e0730d740..3243c6c642 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -23,7 +23,6 @@ import { registerChatNodeRenderers } from './chat/register-node-renderers.ts' import { StatsLine } from './chat/StatsLine.tsx' import { registerConversationNodes } from './conversation-nodes/register.ts' import { DetailsPanel } from './details/DetailsPanel.tsx' -import { HistoricalImageCache } from './historical-images.ts' import { en, NS, zh } from './locale.ts' import { createChatStore } from './stores.ts' @@ -74,7 +73,6 @@ export function apply(ctx: Context): void { const t = ctx.locale.bind(NS) const chatStore = createChatStore() const chatScrollPositions = new Map() - const images = new HistoricalImageCache(ctx) ctx.slots.inject('conversation.view', () => { const disposeView = ctx.slots.register({ @@ -102,7 +100,7 @@ export function apply(ctx: Context): void { return ctx.uiWorkspace.openPath(resolveWorkspacePath(cwd, path)) }, loadOlder: () => { void session.loadOlder() }, - loadImage: attachment => images.resolve(sessionId, attachment), + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), chatScroll: { save: (position) => { if (position === null) chatScrollPositions.delete(sessionId) diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index 88c2f66f68..a647ad5c18 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -2,9 +2,11 @@ // otherwise this view owns it. Each row subscribes to one stable node key. import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import type { ConversationTimelineSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { + ConversationTimelineSnapshot, RenderMessageImages, +} from '@deepseek-ai/dsh-client-ui-conversation/client' import { Button, IconChevronDownOutline14, Modal } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps, RenderMessageImages } from '../contract/slots.ts' +import type { ChatViewSlotProps } from '../contract/slots.ts' import { PendingSteeringBubble } from './MessageItem.tsx' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { formatRunDuration } from './message-chrome.ts' diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index 5009781fd1..cff42849eb 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -1,9 +1,8 @@ /** Chat-owned Slot declarations and composed component props. */ -import type { ReactNode } from 'react' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { MessageId } from '@deepseek-ai/dsh-llm/brand' import type { - ConversationTurnDataMap, TurnLocation, + ConversationTurnDataMap, MessageImagesOwnerProps, RenderMessageImages, TurnLocation, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SlotHookFactory, @@ -19,16 +18,6 @@ import type { ChatSnapshot, CommandNode, CompactionSummaryNode, ToolCallBlock } /** Selector hook over the current Conversation binding's Chat target. */ export type UseChat = SnapshotSelectorHook -/** Historical image group handed to the optional attachment presentation plugin. */ -export interface MessageImagesOwnerProps { - images: readonly { readonly attachment: ImageAttachmentRef }[] - loadImage: (attachment: ImageAttachmentRef) => Promise - align: 'start' | 'end' -} - -/** Slot-backed renderer used by Chat nodes without importing an attachment implementation. */ -export type RenderMessageImages = (owner: Omit) => ReactNode - /** Owner currency of the completed-Turn extension chain. */ export interface TurnTailOwnerProps { turn: TurnLocation diff --git a/packages/client/ui-chat/src/client/index.ts b/packages/client/ui-chat/src/client/index.ts index a847b039fd..c04c2c588e 100644 --- a/packages/client/ui-chat/src/client/index.ts +++ b/packages/client/ui-chat/src/client/index.ts @@ -28,8 +28,7 @@ export type { AssistantActionOwnerProps, ChatFileMentions, ChatNodeOwnerProps, ChatNodeTurnDataInjected, ChatNodeViewProps, ChatScrollPosition, ChatStore, ChatViewInjected, ChatViewSlotProps, CommandRowOwnerProps, CommandRowProps, DetailsInjected, DetailsSlotProps, - DetailsToolOwnerProps, MessageImagesOwnerProps, MessageImagesProps, RenderMessageImages, - TurnTailOwnerProps, UseChat, UseChatNodeTurnData, + DetailsToolOwnerProps, MessageImagesProps, TurnTailOwnerProps, UseChat, UseChatNodeTurnData, } from './contract/slots.ts' export type { ChatKey } from './locale.ts' export type { ConversationContext, ConversationContextOriginKind } from './model/conversation-context.ts' diff --git a/packages/client/ui-chat/tests/image-labels.client.spec.tsx b/packages/client/ui-chat/tests/image-labels.client.spec.tsx index b260850bc3..bb5d339f0a 100644 --- a/packages/client/ui-chat/tests/image-labels.client.spec.tsx +++ b/packages/client/ui-chat/tests/image-labels.client.spec.tsx @@ -5,8 +5,8 @@ import { cleanup, render } 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 type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { AssistantMarkdown } from '../src/client/chat/AssistantMarkdown.tsx' -import type { RenderMessageImages } from '../src/client/contract/slots.ts' import { zh } from '../src/client/locale.ts' afterEach(cleanup) diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 6f37d011f3..f819db101d 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -50,9 +50,6 @@ { "path": "../../runtime-diagnostics/invariants" }, - { - "path": "../../util/crypto" - }, { "path": "../../util/workspace-path" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 85e96fbdca..7169ca2c67 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: 08d1408bea404a80da491078aa013989835fccaf -README.zh.md: 874aea8865e6f74a64da2c249087bdad032d98d5 +README.md: 8506cc9e2d2935151003ad11e34d056dda70e0ff +README.zh.md: bf6643112878f6537d3db1d1686db046202fada5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 08d1408bea..8506cc9e2d 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. +`ui-conversation` owns target-neutral Conversation assembly and the shared browser shell. It consumes Session Controller event feeds, exposes React-free registries and per-Session bindings through `ctx.uiConversation`, and contributes the `useConversation`, `useInput`, and `inputActions` standard props through `ctx.uiSession`. It also owns the per-session durable image URL cache: `ctx.uiConversation.imageUrl(sessionId, attachment)` resolves one session-authorized browser URL per attachment and revokes it with the Session binding, so every Conversation target shares one `session.attachment` read. Concrete targets such as Chat are separate packages that register their own Definitions, snapshot builders, Views, and renderers. ## Conversation assembly diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 874aea8865..bf66431128 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 +`ui-conversation` 拥有与 target 无关的 Conversation 组装和共享浏览器 shell。它消费 Session Controller event feed,通过 `ctx.uiConversation` 暴露不依赖 React 的 registry 与逐 Session binding,并通过 `ctx.uiSession` 提供 `useConversation`、`useInput` 和 `inputActions` 标准 props。它还拥有按会话的持久化图片 URL 缓存:`ctx.uiConversation.imageUrl(sessionId, attachment)` 为每个附件解析一个经会话授权的浏览器 URL,并随 Session binding 释放而撤销,因此所有 Conversation target 共享一次 `session.attachment` 读取。Chat 等具体 target 位于独立 package,由各自 package 注册 Definition、snapshot builder、View 和 renderer。 ## Conversation 组装 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 57c686383b..a7865d5906 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -1,5 +1,6 @@ /** Target-neutral Conversation slot declarations and composed component props. */ import type { ReactNode, RefObject } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { @@ -43,6 +44,19 @@ export interface ComposerAttachmentsOwnerProps { dropLimits?: { readonly count: number; readonly size: string } | undefined } +/** Durable image group handed to the optional attachment presentation plugin. */ +export interface MessageImagesOwnerProps { + /** Durable image references in source order. */ + images: readonly { readonly attachment: ImageAttachmentRef }[] + /** Session-authorized image URL loader. */ + loadImage: (attachment: ImageAttachmentRef) => Promise + /** Horizontal placement inside the owning record. */ + align: 'start' | 'end' +} + +/** Slot-backed renderer used by Conversation targets without importing an attachment implementation. */ +export type RenderMessageImages = (owner: Omit) => ReactNode + /** Selector hook over the current Session's assembled Conversation. */ export type UseConversation = SnapshotSelectorHook /** Selector hook over the registered Conversation View roster. */ diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index 58ea1bf79d..26801e161b 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -1,5 +1,6 @@ /** Per-Session target-neutral Conversation assembly. */ import { Service, type Context } from '@deepseek-ai/cordis' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions, SessionBinding, SessionEventSource, SessionEventWindow, } from '@deepseek-ai/dsh-api-session-controller/client' @@ -15,6 +16,7 @@ import type { import type { ConversationSnapshot } from '../contract/snapshot.ts' import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' +import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ @@ -144,6 +146,7 @@ export class UiConversation extends Service { /** Registry of target View definitions. */ readonly views: ConversationViewRegistry private readonly bindings = new Map() + private readonly images: HistoricalImageCache /** * @param ctx - owning Client context. @@ -153,6 +156,7 @@ export class UiConversation extends Service { super(ctx, 'uiConversation') this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) + this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values()) record.binding.rebuild() } @@ -202,6 +206,17 @@ export class UiConversation extends Service { return binding } + /** + * Resolve one session-authorized durable image URL, cached per Session so + * every Conversation target shares one read and one browser URL. + * @param sessionId - Session authorization and lifetime scope. + * @param attachment - Durable image reference from a session event. + * @returns browser URL valid until the Session binding is released. + */ + imageUrl(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { + return this.images.resolve(sessionId, attachment) + } + private drop(record: BindingRecord, releaseScope: boolean): void { if (this.bindings.get(record.source.sessionId) !== record) return this.bindings.delete(record.source.sessionId) diff --git a/packages/client/ui-chat/src/client/historical-images.ts b/packages/client/ui-conversation/src/client/conversation/historical-images.ts similarity index 80% rename from packages/client/ui-chat/src/client/historical-images.ts rename to packages/client/ui-conversation/src/client/conversation/historical-images.ts index 1e53e78bb8..602b104c1d 100644 --- a/packages/client/ui-chat/src/client/historical-images.ts +++ b/packages/client/ui-conversation/src/client/conversation/historical-images.ts @@ -1,4 +1,4 @@ -/** Session-scoped historical image URL cache owned by the Chat plugin. */ +/** Session-scoped durable image URL cache shared by Conversation targets. */ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' @@ -11,9 +11,8 @@ interface ImageUrlEntry { readonly pending: Promise } -/** Resolve durable Chat images and release their browser URLs with Session scope. */ +/** Resolve durable Conversation images and release their browser URLs with Session scope. */ export class HistoricalImageCache { - private readonly sessions: ISessions private readonly entries = new Map() private readonly generations = new Map() private readonly scopeDisposers = new Map void>() @@ -21,11 +20,11 @@ export class HistoricalImageCache { private disposed = false /** - * @param ctx - Owning ui-chat fiber. + * @param ctx - Owning ui-conversation fiber. + * @param sessions - Session Controller object layer. */ - constructor(ctx: Context) { - this.sessions = ctx.sessions - ctx.effect(() => () => { this.dispose() }, 'ui-chat historical image cache') + constructor(ctx: Context, private readonly sessions: ISessions) { + ctx.effect(() => () => { this.dispose() }, 'ui-conversation historical image cache') } /** @@ -35,22 +34,22 @@ export class HistoricalImageCache { * @returns browser URL valid until the Session binding is released. */ resolve(sessionId: SessionId, attachment: ImageAttachmentRef): Promise { - if (this.disposed) return Promise.reject(new Error('ui-chat image cache is disposed')) + if (this.disposed) return Promise.reject(new Error('ui-conversation image cache is disposed')) const key = `${sessionId}:${attachment.attachmentId}` const cached = this.entries.get(key) if (cached !== undefined) return cached.pending const binding = this.sessions.binding(sessionId) if (binding === undefined) { - return Promise.reject(new Error(`ui-chat: unknown session "${sessionId}"`)) + return Promise.reject(new Error(`ui-conversation: unknown session "${sessionId}"`)) } this.bindScope(sessionId, binding.ctx) const generation = this.generations.get(sessionId) ?? 0 const pending = binding.session.readAttachment(attachment.attachmentId) .then((result) => { if (!result.ok) throw new Error(`${result.error.code}: ${result.error.message}`) - if (this.disposed) throw new Error('ui-chat image cache was disposed before loading completed') + if (this.disposed) throw new Error('ui-conversation image cache was disposed before loading completed') if ((this.generations.get(sessionId) ?? 0) !== generation) { - throw new Error('ui-chat image scope was released before loading completed') + throw new Error('ui-conversation image scope was released before loading completed') } if (typeof URL.createObjectURL !== 'function') { return `data:${result.value.attachment.mediaType};base64,${bytesToBase64(result.value.data)}` @@ -73,7 +72,7 @@ export class HistoricalImageCache { const dispose = scope.effect(() => () => { this.scopeDisposers.delete(sessionId) this.release(sessionId) - }, 'ui-chat historical image scope') + }, 'ui-conversation historical image scope') this.scopeDisposers.set(sessionId, () => { void dispose() }) } diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 0d6874f5a3..966135c043 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -51,7 +51,7 @@ export type { ConversationSessionInjected, ConversationSessionSlotProps, ConversationSlotProps, ConversationStore, ConvViewOwnerProps, ConvViewProps, EmptyWorkspaceOwnerProps, HeroAgentPresetOwnerProps, HeroBrandMarkOwnerProps, InputControlOwnerProps, InputZone, - UseConversation, UseConversationViews, + MessageImagesOwnerProps, RenderMessageImages, UseConversation, UseConversationViews, } from './contract/slots.ts' export type { ArbitrateKey, ArbitrateOutcome, BeginCommandRequest, CommandClaim, ConsumeTokenRequest, diff --git a/packages/client/ui-chat/tests/historical-images.client.spec.ts b/packages/client/ui-conversation/tests/historical-images.client.spec.ts similarity index 79% rename from packages/client/ui-chat/tests/historical-images.client.spec.ts rename to packages/client/ui-conversation/tests/historical-images.client.spec.ts index 1642923a31..71aa3d6018 100644 --- a/packages/client/ui-chat/tests/historical-images.client.spec.ts +++ b/packages/client/ui-conversation/tests/historical-images.client.spec.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import type { SessionFace } from '@deepseek-ai/dsh-api-session-controller/client' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' -import { HistoricalImageCache } from '../src/client/historical-images.ts' +import { HistoricalImageCache } from '../src/client/conversation/historical-images.ts' describe('HistoricalImageCache', () => { it('invalidates a pending image load when its Session binding is released', async () => { @@ -13,7 +13,7 @@ describe('HistoricalImageCache', () => { id: 's1', session: { readAttachment: () => read.promise }, }) - const cache = new HistoricalImageCache(runtime.ctx) + const cache = new HistoricalImageCache(runtime.ctx, runtime.ctx.sessions) const attachment = { attachmentId: AttachmentId('image-1'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, } as const @@ -22,7 +22,7 @@ describe('HistoricalImageCache', () => { await runtime.sessions.remove(sessionId) read.resolve({ ok: true, value: { attachment, data: Uint8Array.of(1) } }) - await expect(pending).rejects.toThrow('ui-chat image scope was released before loading completed') + await expect(pending).rejects.toThrow('ui-conversation image scope was released before loading completed') await runtime.dispose() }) }) diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 47b35ae17d..96f48c6888 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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-trajectory/README.md -README.md: 97badd562bbf132c9766c763ff604306d1a6c08d -README.zh.md: 5a17ee811047e8ffd15be849595d87adfe4ddf00 +README.md: c623105b9bb84edbd8ff6a91244271f6fc92d943 +README.zh.md: 007f4451be9ac2f19c7fdd8e6eefe410813c7eb7 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 97badd562b..c623105b9b 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Durable image attachments in user input, assistant output, and tool results render through the `conversation.trajectory.images` gallery slot: a record without text labels its row with the image count, the inspector shows each image with the shared loading, retry, and lightbox behavior, and image URLs come from the Conversation-owned per-session cache, so Chat and Trajectory share one authorized read per attachment. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned. While an older prefix remains unloaded, a first-row control precedes the loaded records, loads one earlier page on click, and changes in place to a disabled loading status while that page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including durable cancellation-finalized prefixes, chunk-only interruption fallbacks, and interrupted Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Its typed `trajectory` locale namespace owns every product-authored ledger, timeline, inspector, tooltip, and accessibility phrase; event content, tool names, identifiers, and provider diagnostics remain verbatim data. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 5a17ee8110..007f4451be 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。用户输入、助手输出和工具结果中的持久化图片附件通过 `conversation.trajectory.images` 画廊 slot 渲染:没有文本的记录行以图片数量标注,检查器内展示每张图片并复用共享的加载、重试与灯箱行为,图片 URL 来自 Conversation 持有的按会话缓存,因此 Chat 与 Trajectory 对同一附件共享一次经会话授权的读取。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前,记录表会用明确的加载行遮住真实记录。更早的前缀仍未加载时,已加载记录前会始终保留首行控件;单击它会加载一页更早的历史,页面加载期间则会原地变为禁用的加载状态。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括持久化的取消定稿前缀、只能从分片恢复的打断前缀和被打断的工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。其 typed `trajectory` locale namespace 持有 ledger、时间线、检查器、tooltip 与无障碍短语中的全部产品编写文案;事件内容、工具名称、标识符与提供方诊断仍作为数据原样呈现。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 5f4fa4981d..054dc9ddf9 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -62,7 +62,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -85,7 +86,8 @@ "@deepseek-ai/dsh-api-session-controller": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", "@deepseek-ai/dsh-client-ui-session": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^" + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css index 71d0b3031b..4602f925af 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.module.css @@ -1545,35 +1545,6 @@ white-space: pre-wrap; } -.panelImageLink { - display: block; - width: auto; - max-width: 100%; - overflow: hidden; - border-radius: 4px; - cursor: zoom-in; -} - -.panelImageLinkPreview { - max-height: 140px; -} - -.panelImage { - display: block; - width: auto; - max-width: 100%; - height: auto; - max-height: 320px; - margin: 0; - border-radius: inherit; - background: var(--dsw-alias-bg-base); - object-fit: contain; -} - -.panelImageLinkPreview .panelImage { - max-height: 140px; -} - .messageImages { display: flex; flex-direction: column; diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index b3f5f3415e..81a428aa35 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -14,8 +14,9 @@ import { } from '@deepseek-ai/dsh-client-ui-primitives' import type { JsonTreeLabels, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import { structuredPatch } from 'diff' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantRequestConfig, ConversationPromptSnapshot, + AssistantRequestConfig, ConversationPromptSnapshot, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AssistantMetricDetail, TrajectoryCellKind, TrajectoryCellProps, TrajectorySourceBlock, @@ -377,6 +378,8 @@ function AssistantTimingPanel({ export interface TrajectoryTableProps { /** Trajectory locale seat. */ t: TrajectoryTranslate + /** Slot-backed durable image renderer shared with the Chat gallery. */ + renderImages: RenderMessageImages /** Session-global request numbers for the request groups visible in this context. */ requestNumbers?: readonly TrajectoryRequestNumber[] /** Grouped records in display order. */ @@ -1121,10 +1124,12 @@ function MarkdownFragment({ function SourceBlocks({ blocks, onOpenCall, + renderImages, t, }: { blocks: readonly TrajectorySourceBlock[] onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { return ( @@ -1155,8 +1160,10 @@ function SourceBlocks({ )} - {block.imageSrc !== undefined - ? + {/* The Raw view keeps model block order and granularity: one + gallery per image block, unlike the aggregated record gallery. */} + {block.attachment !== undefined + ? renderImages({ images: [{ attachment: block.attachment }], align: 'start' }) :
{block.content}
} ))} @@ -1164,47 +1171,27 @@ function SourceBlocks({ ) } -function PanelImage({ - block, - preview = false, - t, -}: { - block: TrajectorySourceBlock - preview?: boolean - t: TrajectoryTranslate -}) { - if (block.imageSrc === undefined) return null - return ( - - {block.imageAlt - - ) +function recordImages( + blocks: readonly TrajectorySourceBlock[] | undefined, +): { readonly attachment: ImageAttachmentRef }[] { + return (blocks ?? []).flatMap(block => + block.attachment !== undefined ? [{ attachment: block.attachment }] : []) } function MessageImages({ blocks, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] | undefined preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { - const images = blocks?.filter(block => block.imageSrc !== undefined) ?? [] + const images = recordImages(blocks) if (images.length === 0) return null return (
- {images.map((block, index) => )} + {renderImages({ images, align: 'start' })}
) } @@ -1403,13 +1390,16 @@ function SystemPromptDiff({ function ToolOutputBlocks({ blocks, error, + errorDetail, preview, - t, + renderImages, }: { blocks: readonly TrajectorySourceBlock[] error: boolean + /** Failure name and code preserved beside image-only error content. */ + errorDetail?: string | undefined preview: boolean - t: TrajectoryTranslate + renderImages: RenderMessageImages }) { return (
value !== undefined).join(' ')} > + {error && errorDetail !== undefined && errorDetail !== '' + &&
{errorDetail}
} {blocks.map((block, index) => ( - block.imageSrc !== undefined - ? + block.attachment !== undefined + ? ( +
+ {renderImages({ images: [{ attachment: block.attachment }], align: 'start' })} +
+ ) : block.content !== '' ?
{block.content}
: null @@ -1436,6 +1432,7 @@ function MarkdownRecordContent({ thinkingExpanded, onThinkingExpandedChange, onOpenCall, + renderImages, t, }: { record: TableRecord @@ -1444,10 +1441,18 @@ function MarkdownRecordContent({ thinkingExpanded: boolean onThinkingExpandedChange: (expanded: boolean) => void onOpenCall: (callId: string) => void + renderImages: RenderMessageImages t: TrajectoryTranslate }) { if (!rendered && record.cell.sourceBlocks && record.cell.sourceBlocks.length > 0) { - return + return ( + + ) } if (record.cell.thinkingDetail) { if (!rendered) { @@ -1502,13 +1507,13 @@ function MarkdownRecordContent({
) } const source = markdownSource(record) - const hasImages = record.cell.sourceBlocks?.some(block => block.imageSrc !== undefined) === true + const hasImages = record.cell.sourceBlocks?.some(block => block.attachment !== undefined) === true const hasToolCalls = record.cell.kind === 'message' && record.cell.sourceBlocks?.some(block => block.type === 'tool-call') === true if (!source && !hasImages && !hasToolCalls) { @@ -1531,7 +1536,7 @@ function MarkdownRecordContent({ t={t} /> )} - + ) } @@ -1590,11 +1595,13 @@ function RecordPayload({ record, direction, preview = false, + renderImages, t, }: { record: TableRecord direction: 'input' | 'output' preview?: boolean + renderImages: RenderMessageImages t: TrajectoryTranslate }) { const value = direction === 'input' ? record.cell.inputDetail : record.cell.outputDetail @@ -1624,14 +1631,15 @@ function RecordPayload({ if ( direction === 'output' && record.cell.outputBlocks?.some(block => - block.imageSrc !== undefined || block.content !== '') === true + block.attachment !== undefined || block.content !== '') === true ) { return ( ) } @@ -1791,6 +1799,7 @@ function OverviewSection({ */ export function TrajectoryTable({ t, + renderImages, requestNumbers: sessionRequestNumbers, turns, streamingCells = [], @@ -2990,6 +2999,7 @@ export function TrajectoryTable({ > { activateTab('rendered') }}> {selected.cell.inputDetail && ( { activateTab('input') }}> - + )} {selected.cell.outputDetail && ( { activateTab('output') }}> - + )} { activateTab('schema') }}> @@ -3151,6 +3162,7 @@ export function TrajectoryTable({ {!promptSelected && selected !== undefined && activeTab === 'rendered' && ( )} {!promptSelected && selected !== undefined && activeTab === 'input' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'output' && ( - + )} {!promptSelected && selected !== undefined && activeTab === 'schema' && ( diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 1e36f4431b..8b982b1649 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,10 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { - AssistantBlock, AssistantMessageNode, ConvViewProps, + AssistantBlock, AssistantMessageNode, ConvViewProps, RenderMessageImages, } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import { TrajectoryTable, @@ -69,6 +70,7 @@ export interface TrajectoryViewInjected { duration: SnapshotStore } loadOlder: () => Promise + loadImage: (attachment: ImageAttachmentRef) => Promise setActualDuration: (actualDuration: boolean) => void } @@ -117,10 +119,17 @@ function addUsage( } export function TrajectoryView({ - useSession, useTrajectory, useDuration, loadOlder, setActualDuration, - viewRequest, completeViewRequest, t, -}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { + useSession, useTrajectory, useDuration, loadOlder, loadImage, setActualDuration, + viewRequest, completeViewRequest, renderSlot, t, +}: ConvViewProps + & PropsRenderSlots<'conversation.trajectory.images'> + & InjectFace + & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) + const renderImages = useCallback( + owner => renderSlot('conversation.trajectory.images', { ...owner, loadImage }), + [loadImage, renderSlot], + ) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) const [timelineSelection, setTimelineSelection] = useState(null) @@ -481,6 +490,7 @@ export function TrajectoryView({
t('view.trajectory'), + children: { + 'conversation.trajectory.images': { kind: 'single', scope: 'session' }, + }, inject: (sessionId: SessionId): TrajectoryViewInjected => { const session = ctx.sessions.binding(sessionId)?.session if (session === undefined) { @@ -92,6 +95,7 @@ export function apply(ctx: Context): void { await session.loadOlder() return trajectory.getSnapshot() !== before }, + loadImage: attachment => ctx.uiConversation.imageUrl(sessionId, attachment), setActualDuration: (value) => { duration.set(value) }, } }, diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 88aa5c0cbf..a811e915f2 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,6 +12,7 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -108,7 +109,7 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number { : entry.seq } -function inputCellDetail(node: InputNode): Pick< +function inputCellDetail(node: InputNode, t: TrajectoryTranslate): Pick< TrajectoryCellProps, | 'text' | 'previewMarkdown' @@ -119,9 +120,15 @@ function inputCellDetail(node: InputNode): Pick< | 'timeSeconds' | 'startedAt' > { - const previewMarkdown = previewContent(node.content) + // An empty text block yields an empty preview; treat it as absent so an + // image-bearing record still labels its row instead of rendering blank. + const preview = previewContent(node.content) + const previewMarkdown = preview === '' ? undefined : preview + const images = imageBlockCount(node.content) return { - text: '', + text: previewMarkdown === undefined && images > 0 + ? t('layout.imageOnly', { count: images }) + : '', ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, @@ -368,7 +375,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user', - ...inputCellDetail(node), + ...inputCellDetail(node, t), opensTurn: true, }, }) @@ -387,7 +394,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'user' as const, - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, } if (placement.step === undefined) pushMessage(placement.turn, laid) @@ -415,7 +422,7 @@ export function deriveTrajectoryLayout( cell: { index: ++index, kind: 'context', - ...inputCellDetail(node), + ...inputCellDetail(node, t), }, }) prevAbsTime = finiteTime(node.time) ?? prevAbsTime @@ -788,6 +795,8 @@ function summarizeAssistantActivity( if (tools.size > 0) { return t('layout.toolCallOnly') } + const images = blocks.filter(block => block.kind === 'image').length + if (images > 0) return t('layout.imageOnly', { count: images }) return '' } @@ -808,12 +817,7 @@ 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 'image': return { type: 'image', content: '', attachment: block.attachment } case 'other': return sourceBlock(block.block) } } @@ -827,47 +831,21 @@ function sourceBlock(value: unknown): TrajectorySourceBlock { if (typeof block.text === 'string') { return { type: type === 'reasoning' ? 'thinking' : type, content: block.text } } - const imageSrc = sourceImage(block) - const imageAlt = typeof block.alt === 'string' ? block.alt : undefined - return { - type, - content: imageSrc === undefined ? stringifySourceValue(value) : '', - ...(imageSrc !== undefined ? { imageSrc } : {}), - ...(imageAlt !== undefined ? { imageAlt } : {}), + if ( + type === 'image' + && typeof block.attachment === 'object' && block.attachment !== null + && typeof (block.attachment as Record).attachmentId === 'string' + ) { + // Session-log content is validated into core ContentBlocks by the + // Conversation node assembly; the `attachmentId` guard only keeps + // wire-shaped 'other' blocks with an unrelated `attachment` member out. + return { type, content: '', attachment: block.attachment as ImageAttachmentRef } } + return { type, content: stringifySourceValue(value) } } -function sourceImage(block: Record): string | undefined { - if (typeof block.type !== 'string' || !block.type.toLowerCase().includes('image')) return undefined - for (const candidate of [block.url, block.image_url]) { - if (typeof candidate === 'string') return safeImageSource(candidate) - } - if (typeof block.data === 'string') { - const mediaType = [block.mimeType, block.mediaType, block.media_type] - .find((candidate): candidate is string => typeof candidate === 'string') - ?? 'image/png' - return safeImageSource( - block.data.startsWith('data:') - ? block.data - : `data:${mediaType};base64,${block.data}`, - ) - } - if (typeof block.source !== 'object' || block.source === null) return undefined - const source = block.source as Record - if (typeof source.url === 'string') return safeImageSource(source.url) - if (typeof source.data !== 'string') return undefined - const mediaType = typeof source.media_type === 'string' ? source.media_type : 'image/png' - return safeImageSource(`data:${mediaType};base64,${source.data}`) -} - -function safeImageSource(value: string): string | undefined { - if (value.startsWith('data:image/') || value.startsWith('blob:')) return value - try { - const protocol = new URL(value).protocol - return protocol === 'http:' || protocol === 'https:' ? value : undefined - } catch { - return undefined - } +function imageBlockCount(content: readonly { type: string }[]): number { + return content.filter(block => block.type === 'image').length } function stringifySourceValue(value: unknown): string { @@ -1087,6 +1065,8 @@ function summarizeResult( return { result: '', resultPreviewMarkdown: block.text } } } + const images = imageBlockCount(node.content) + if (images > 0) return { result: t('layout.imageOnly', { count: images }) } return { result: t('record.noOutput') } } @@ -1112,6 +1092,8 @@ function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { .map(block => block.type === 'text' ? block.text : '') .join('\n') if (text !== '') return text + const images = imageBlockCount(node.content) + if (images > 0) return t('layout.imageOnly', { count: images }) if ( node.content.length === 0 || node.content.every(block => diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts index 70707741a3..ddc5cea7c7 100644 --- a/packages/client/ui-trajectory/src/client/locales.ts +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -121,7 +121,6 @@ export const zh = { 'block.openSummary': '打开第 {index} 个块的工具调用概述', 'block.openSummaryTitle': '打开工具调用概述', 'block.label': '块 #{index} {type}', - 'block.openImage': '打开图片', 'history.loadingTrajectory': '正在加载轨迹…', 'history.loadingEarlier': '正在加载更早的历史…', 'history.loadingEarlierAria': '正在加载更早的历史…', @@ -174,6 +173,7 @@ export const zh = { 'layout.compactionFailed': '上下文压缩失败', 'layout.compacted': '上下文已压缩', 'layout.toolCallOnly': '仅工具调用', + 'layout.imageOnly': '图片 ×{count}', 'layout.initialSystemPrompt': '初始系统提示词', 'layout.systemPromptUpdated': '系统提示词已更新', 'layout.toolsUpdated': '工具已更新', @@ -313,7 +313,6 @@ export const en: Record = { 'block.openSummary': 'Open Block #{index} tool call summary', 'block.openSummaryTitle': 'Open tool call summary', 'block.label': 'Block #{index} {type}', - 'block.openImage': 'Open image', 'history.loadingTrajectory': 'Loading trajectory…', 'history.loadingEarlier': 'Loading earlier history…', 'history.loadingEarlierAria': 'Loading earlier history…', @@ -366,6 +365,7 @@ export const en: Record = { 'layout.compactionFailed': 'Compaction failed', 'layout.compacted': 'Context compacted', 'layout.toolCallOnly': 'Tool call only', + 'layout.imageOnly': 'Images ×{count}', 'layout.initialSystemPrompt': 'Initial System Prompt', 'layout.systemPromptUpdated': 'System Prompt Updated', 'layout.toolsUpdated': 'Tools Updated', diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index 5a96479bee..37571f2b83 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -1,7 +1,7 @@ import type { AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot, - ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall, - ToolCallBlock, + ConversationViewNode, MessageImagesOwnerProps, PartialAssistant, RequestPromptChange, + RequestView, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -84,4 +84,14 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { /** Selector hook over the current Conversation binding's Trajectory target. */ useTrajectory: UseTrajectory } + + interface SlotMap { + /** + * Renderer for one group of durable record images in the Trajectory + * ledger. The owner supplies image references, an authorized loader, and + * alignment. A registration replaces the shipped gallery; without one, + * images are omitted. + */ + 'conversation.trajectory.images': { kind: 'single'; scope: 'session'; owner: MessageImagesOwnerProps } + } } diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 43fa1b24a8..da330f2ae4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -1,6 +1,7 @@ /** Shared trajectory record data and formatting contracts. */ import type { HTMLAttributes } from 'react' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { ConversationPromptSnapshot } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { TrajectoryTranslate } from './locales.ts' @@ -28,8 +29,7 @@ export interface AssistantMetricDetail { export interface TrajectorySourceBlock { type: string content: string - imageSrc?: string - imageAlt?: string + attachment?: ImageAttachmentRef callId?: string toolName?: string } diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts index 93dddb6856..9641fce1f9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -64,7 +64,7 @@ function recordSources( block.content, block.callId ?? '', block.toolName ?? '', - block.imageAlt ?? '', + block.attachment?.name ?? '', ]), searchableJson(cell.messageSource), searchableJson(cell.promptDetail), diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 6df422e064..fbb454decf 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -566,3 +566,103 @@ describe('run_code sub-dispatch cells', () => { expect(cells.map(cell => cell.index)).toEqual([1, 2, 3, 4]) }) }) + +describe('durable image attachments', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } + + it('carries user image refs into sourceBlocks and labels an image-only record', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', attachment }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×2') + expect(user?.previewMarkdown).toBeUndefined() + expect(user?.sourceBlocks).toEqual([ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ]) + }) + + it('labels a record whose only text block is empty as image-only', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: '' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('Images ×1') + expect(user?.previewMarkdown).toBeUndefined() + }) + + it('keeps the text preview when a user message mixes text and images', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'text', text: 'look at this' }, { type: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const user = turns[0]?.groups[0]?.cells[0] + expect(user?.text).toBe('') + expect(user?.previewMarkdown).toBe('look at this') + expect(user?.sourceBlocks?.[1]).toEqual({ type: 'image', content: '', attachment }) + }) + + it('maps assistant image blocks to attachment source blocks and labels image-only output', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 0, + blocks: [{ kind: 'image', attachment }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') + expect(message?.text).toBe('Images ×1') + expect(message?.sourceBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('carries tool-result image refs into outputBlocks and labels the result', () => { + const nodes = [ + { + kind: 'assistant', seq: 1, time: 1_000, turn: 1, step: 1, + blocks: [{ kind: 'tool-call', callId: 'c1', name: 'read_image', argsRaw: '{}' }], + }, + { + kind: 'tool-result', seq: 2, time: 2_000, callId: 'c1', + call: { name: 'read_image', argsRaw: '{}' }, callTime: 1_200, + content: [{ type: 'image', attachment }], isError: false, + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') + expect(tool?.result).toBe('Images ×1') + expect(tool?.outputDetail).toBe('Images ×1') + expect(tool?.outputBlocks).toEqual([{ type: 'image', content: '', attachment }]) + }) + + it('shows wire-shaped blocks without an attachment as JSON, not as an image', () => { + const nodes = [ + { + kind: 'user', seq: 1, time: 1_000, source: null, + content: [{ type: 'image', url: 'https://example.com/a.png' }], + }, + ] as unknown as LegacyConversationSlice['nodes'] + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + const block = turns[0]?.groups[0]?.cells[0]?.sourceBlocks?.[0] + expect(block?.attachment).toBeUndefined() + expect(block?.content).toContain('https://example.com/a.png') + }) +}) diff --git a/packages/client/ui-trajectory/tests/table.client.spec.tsx b/packages/client/ui-trajectory/tests/table.client.spec.tsx index 7e3b4bee2c..9bd8de74f0 100644 --- a/packages/client/ui-trajectory/tests/table.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.client.spec.tsx @@ -4,12 +4,24 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ComponentProps } from 'react' +import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable as LocalizedTrajectoryTable } from '../src/client/TrajectoryTable.tsx' import type { TrajectoryTurnModel } from '../src/client/layout.ts' import { trajectoryRecordId } from '../src/client/trajectory-record.ts' import { t, tZh } from './locale.client.ts' -function TrajectoryTable(props: Omit, 't'>) { +const renderImagesStub: RenderMessageImages = ({ images }) => ( +
+ {images.map((image, index) => ( + + ))} +
+) + +function TrajectoryTable( + props: Omit, 't' | 'renderImages'> + & { renderImages?: RenderMessageImages }, +) { const inferred: Array[number] & { firstIndex: number }> = [] for (const turn of props.turns) { for (const group of turn.groups) { @@ -40,7 +52,14 @@ function TrajectoryTable(props: Omit left.firstIndex - right.firstIndex) .map(({ firstIndex: _firstIndex, ...request }, index) => ({ ...request, number: index + 1 })) - return + return ( + + ) } afterEach(() => { @@ -129,6 +148,7 @@ describe('TrajectoryTable', () => { render( ()} onToggleTurn={() => {}} @@ -937,6 +957,120 @@ describe('TrajectoryTable', () => { expect(screen.getByText('value:')).toBeTruthy() }) + it('renders user image attachments through the shared gallery in the details panel', () => { + const attachment = { + attachmentId: `sha256:${'a'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 640, + height: 320, + name: 'screenshot.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Message', + cells: [{ + index: 1, + kind: 'user', + text: 'Images ×2', + sourceBlocks: [ + { type: 'image', content: '', attachment }, + { type: 'image', content: '', attachment }, + ], + timeSeconds: 0, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /USER/ })) + + const preview = screen.getAllByTestId('record-images') + expect(preview.length).toBeGreaterThan(0) + expect(preview[0]?.getAttribute('data-count')).toBe('2') + + fireEvent.click(screen.getByRole('tab', { name: 'Raw' })) + const rawGalleries = screen.getAllByTestId('record-images') + expect(rawGalleries).toHaveLength(2) + expect(rawGalleries[0]?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + + it('renders a tool-result image through the shared gallery in the Result tab', () => { + const attachment = { + attachmentId: `sha256:${'b'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 640, + name: 'capture.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'read_image {"path":"a.png"}', + outputDetail: 'Images ×1', + outputBlocks: [{ type: 'image', content: '', attachment }], + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + expect(gallery?.querySelector('[data-attachment-id]')?.getAttribute('data-attachment-id')) + .toBe(String(attachment.attachmentId)) + }) + + it('keeps the failure name beside an image-only error result', () => { + const attachment = { + attachmentId: `sha256:${'c'.repeat(64)}`, + mediaType: 'image/png', + bytes: 68, + width: 320, + height: 320, + name: 'failed.png', + } as unknown as NonNullable< + NonNullable[number]['attachment'] + > + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 1', + cells: [{ + index: 1, + kind: 'tool', + text: 'render {"target":"chart"}', + outputDetail: 'ToolError: RENDER_TRUNCATED', + outputBlocks: [{ type: 'image', content: '', attachment }], + isError: true, + timeSeconds: 0.1, + }], + }], + }] + + render() + fireEvent.click(screen.getByRole('row', { name: /TOOL/ })) + fireEvent.click(screen.getByRole('tab', { name: 'Result' })) + + expect(screen.getByText('ToolError: RENDER_TRUNCATED')).toBeTruthy() + const gallery = screen.getAllByTestId('record-images').at(-1) + expect(gallery?.getAttribute('data-count')).toBe('1') + }) + it('keeps the first row and a compact summary when a turn is collapsed', () => { render( {}, completeViewRequest: () => {}, + // Image seats the outlet would bake: standalone renders omit the gallery. + renderSlot: () => null, + SessionProvider: ({ children }) => <>{children}, + loadImage: () => Promise.reject(new Error('standalone views load no images')), // The locale seat the outlet would inject for the declared namespace. t: tZh, } diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index 8bd120c437..0d0f85fe1d 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -49,6 +49,9 @@ }, { "path": "../../llm/llm" + }, + { + "path": "../../attachment/attachment" } ] } diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index c34ff9547a..dd68b6a139 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/core/system-prompt/README.md -README.md: a52aa3e4c2782993fed5a525cc827aba4e3eaeb0 -README.zh.md: cf0ba43aa2f2f47ea61ef13c74fbf182fbd9f2ee +README.md: d750a507e628e7609af542227e4528d4d4934ce8 +README.zh.md: ec5b32d742b96c8044a3707c35f15e30ba642b4f diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index a52aa3e4c2..d750a507e6 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -11,7 +11,6 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem | `includeHarnessIdentity` | `true` | Include the fixed `You are an AI agent powered by DeepSeek Harness.` order-−100 opener. Set false only when a compatibility deployment owns the complete system prompt. | | `includeRuntimeContext` | `true` | Include ordered dynamic contexts in assembly. When false, context providers are not evaluated and contexts added by `system-prompt/assemble` listeners are discarded after the waterfall; other services and their enforcement remain active. | | `persona` | `''` | The global deployment-persona default: the ONE config-authored prompt fragment, rendered as the order-0 `deployment:persona` section unless an agent-scoped contribution shadows it. A template — complete `{{…}}` groups are interpreted strictly against the registered variables (the shipped loop registers `{{model}}`/`{{cwd}}`), with no escape syntax for literal braces yet. Empty ⇒ the section is dropped at render. | -| `personaComplete` | `false` | Treat `persona` as the complete system prompt after assembly. Other sections remain registered but are omitted from model requests; tool schemas and variables remain available. | | `toolOrder` | — | Explicit model-facing tool order, as a list of `ToolSchema.name`s with one `''` rest entry (`TOOL_ORDER_REST`): listed tools take their listed position, unlisted tools land at the rest entry in lexicographic name order. Absent ⇒ plain lexicographic name order. Applied to the collected tools BEFORE the `system-prompt/assemble` waterfall — like the sections' `order` sort, it canonicalizes what the registry contributed (registration order is a plugin-load artifact), and a waterfall listener that mutates the list owns the determinism of what it emits. Misconfiguration fails loud: a list without exactly one rest entry, or with duplicates, throws at load; a listed name with no registered tool rejects every `assemble()`; a tool provider returning the reserved rest-entry name also rejects. Under the shipped loop the turn fails before any model request. Why a central list and not per-plugin weights: [Explicit model-facing tool order](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.md). | ## Service: `SystemPrompt` (ctx key: `systemPrompt`) diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index cf0ba43aa2..ec5b32d742 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -11,7 +11,6 @@ | `includeHarnessIdentity` | `true` | 是否包含顺序为 −100 的固定开场白 `You are an AI agent powered by DeepSeek Harness.`。仅当兼容性部署拥有完整系统提示词时设为 false。 | | `includeRuntimeContext` | `true` | 是否在组装中包含有序动态上下文。设为 false 时不会求值上下文提供方,并会在 waterfall 后丢弃 `system-prompt/assemble` 监听器添加的上下文;其他服务及其强制机制仍然生效。 | | `persona` | `''` | 全局部署 persona 默认值:唯一由配置提供的提示词片段,渲染为顺序为 0 的 `deployment:persona` 段,除非 agent 作用域的贡献将其遮蔽。它是模板,完整的 `{{…}}` 组会严格按已注册变量解释(随附循环注册 `{{model}}`/`{{cwd}}`),目前没有表达字面量花括号的转义语法。为空 ⇒ 渲染时删除该段。 | -| `personaComplete` | `false` | 在组装后将 `persona` 作为完整系统提示词。其他段仍保持注册,但不会进入模型请求;工具 schema 与变量仍然可用。 | | `toolOrder` | 无 | 显式指定面向模型的工具顺序。该列表由 `ToolSchema.name` 组成,并且必须恰好包含一个 `''` 其余项标记(`TOOL_ORDER_REST`):已列工具按列表位置排列,未列工具则按名称字典序插入该标记所在的位置。缺席 ⇒ 直接按名称字典序排列。该顺序会在 `system-prompt/assemble` waterfall(瀑布式事件)之前应用于已收集的工具。与段的 `order` 排序一样,它会规范化注册表贡献的内容;注册顺序只是插件加载时序的产物。修改列表的 waterfall 监听器对其输出的确定性负责。配置错误会明确失败:列表没有恰好一个其余项或存在重复项,会在加载时抛出;已列名称没有对应已注册工具,会使每次 `assemble()` 被拒绝;工具提供方返回保留的其余项名称也会被拒绝。在随附循环下,轮次会在任何模型请求前失败。为何采用中心列表而非每插件权重,见[显式面向模型工具顺序](../../../.agents/notes/implemented/feature/2026-07-06-explicit-tool-order.zh.md)。 | ## 服务:`SystemPrompt`(ctx 键:`systemPrompt`) diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index ec36b32432..ffc052e0b9 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -193,8 +193,6 @@ export interface Config { * `deployment:persona` shadows it; `{{variable}}` references are strict. */ persona?: string - /** Treat the deployment persona as the complete system prompt (default false). */ - personaComplete?: boolean /** * Model-facing tool names in order, with {@link TOOL_ORDER_REST} exactly once. * Invalid fields fail at load and unknown names fail at assembly; known names @@ -342,7 +340,6 @@ export class SystemPrompt extends Service { includeHarnessIdentity: z.boolean().default(true), includeRuntimeContext: z.boolean().default(true), persona: z.string().default(''), - personaComplete: z.boolean().default(false), // Preserve omission because an explicit empty order lacks the rest marker. toolOrder: z.array(z.string()).default(undefined as unknown as string[]), }) @@ -369,7 +366,6 @@ export class SystemPrompt extends Service { order: PERSONA_ORDER, // The fallback narrows the optional input type; the schema already defaults it. text: config.persona ?? '', - complete: config.personaComplete ?? false, }) if (!(config.includeRuntimeContext ?? true)) this.suppressRuntimeContext() } diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index c4018103d3..cf196892a7 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -49,21 +49,6 @@ describe('SystemPrompt', () => { expect(renderPrompt(assembly)).toBe('You are a helpful software engineer assistant.') }) - it('can make the deployment persona the complete system prompt', async () => { - const ctx = new Context() - await ctx.plugin(SystemPrompt, { - persona: 'You are a focused SDK agent.', - personaComplete: true, - }) - ctx.systemPrompt.section({ name: 'tool:future', order: 100, text: 'Future tool guidance.' }) - - const assembly = await ctx.systemPrompt.assemble() - expect(assembly.sections).toEqual([ - { name: 'deployment:persona', text: 'You are a focused SDK agent.' }, - ]) - expect(renderPrompt(assembly)).toBe('You are a focused SDK agent.') - }) - it('can suppress runtime context without evaluating providers or accepting waterfall additions', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt, { includeRuntimeContext: false }) diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index dedce4969d..fc06579649 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: 28e1496a8c941f6524ec4b4dfbfad52d17df5d71 -README.zh.md: c2838bb4cf78d6ac863bac2eba4d2e4df335fa55 +README.md: ef82aa4b413be41049dbbd247deee4f1969cdb48 +README.zh.md: 7701da0d965736efd905a3331401752119b160d9 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 28e1496a8c..ef82aa4b41 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -55,11 +55,11 @@ This applies the [Service Definition / Service Provider / Consumer separation](. ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services; `personaComplete: true` makes the deployment persona the sole system-prompt section. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. +The bundle forwards each field to the child that owns it. App packages supply any pre-created agents: headless and JSON-RPC compositions create `main`, while the ACP app creates agents on demand at `session/new`. `includeRuntimeContext: false` suppresses all dynamic context snapshots for fresh sessions without disabling their policy services. Prompt, tool, title, skill, agent-instructions, invariant, goal, and task settings retain the schemas and defaults documented by their owning packages; `jobs.maxConcurrentJobsPerOwner` configures the local provider independently of the model-facing `toolJobs` controls. `pickSpineConfig()` copies only fields owned by this bundle, and conflicting `dshHome` values fail during composition. For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../runtime-diagnostics/invariants/README.md) for regex and lifecycle rules. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index c2838bb4cf..7701da0d96 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -55,11 +55,11 @@ ```ts import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' -// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, personaComplete?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } +// { agents?, maxParallelToolCalls?, includeHarnessIdentity?, includeRuntimeContext?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, jobs?, toolJobs?, goals?, invariants? } // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务;`personaComplete: true` 会让部署 persona 成为唯一系统提示词段。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 +组合包将每个字段转发给拥有它的子节点。应用包提供预创建的 agent:无头和 JSON-RPC 组合会创建 `main`,ACP 应用则在 `session/new` 按需创建 agent。`includeRuntimeContext: false` 会为新建会话抑制所有动态上下文快照,但不禁用其策略服务。提示词、工具、标题、skill、工作区上下文、不变式、目标和任务设置沿用其所属包记录的 schema 与默认值;`jobs.maxConcurrentJobsPerOwner` 配置本地 Service Provider,并与面向模型的 `toolJobs` 控制工具相互独立。`pickSpineConfig()` 只复制该组合包拥有的字段,`dshHome` 值冲突会在组合时失败。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../runtime-diagnostics/invariants/README.zh.md)。 diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index a60ebd9e43..5dafbd363a 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -70,9 +70,9 @@ export interface GoalConfig { * Bundle config: each field forwarded verbatim to the child that owns it — * `agents` to the agent loop (an app that pre-creates no agents, like the ACP * bridge, simply omits it), `includeHarnessIdentity`, `includeRuntimeContext`, - * `persona`, `personaComplete`, and `toolOrder` to the system-prompt plugin - * (the fixed opener, dynamic-context policy, deployment persona completeness, - * and explicit model-facing tool order), the `tools` object to the tool + * `persona`, and `toolOrder` to the system-prompt plugin (the fixed opener, + * dynamic-context policy, deployment persona, and explicit model-facing tool + * order), the `tools` object to the tool * registry (its presentation `mode`), * `dshHome` to bash environment and local skill discovery, `sessionTitle` to * the fallback title service, `skills` to the @@ -101,8 +101,6 @@ export interface Config { includeRuntimeContext?: SystemPromptConfig['includeRuntimeContext'] /** The deployment persona (see dsh-system-prompt's `Config`). */ persona?: SystemPromptConfig['persona'] - /** Whether the deployment persona is the complete system prompt. */ - personaComplete?: SystemPromptConfig['personaComplete'] /** The explicit model-facing tool order (see dsh-system-prompt's `Config`). */ toolOrder?: SystemPromptConfig['toolOrder'] /** The tool registry's config — its presentation `mode` (see dsh-tools' `Config`). */ @@ -188,7 +186,6 @@ export function pickSpineConfig(config: Omit): Omit { await ctx.fiber.dispose() }) - it('can omit the bundled bash tool and Harness identity for a compatibility deployment', async () => { + it('can omit the bundled bash tool, Harness identity, and runtime context', async () => { const ctx = await mount({ includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are a helpful software engineer assistant.', - personaComplete: true, workspaceContext: false, skills: { enabled: false }, toolBash: false, @@ -724,7 +723,6 @@ describe('dsh-agent-spine-demo bundle', () => { expect(ctx.tools.schemas()).toEqual([]) ctx.systemPrompt.context({ name: 'policy', order: 0, text: 'hidden policy' }) expect((await ctx.systemPrompt.assemble()).contexts).toEqual([]) - ctx.systemPrompt.section({ name: 'hidden', order: 100, text: 'hidden guidance' }) expect(renderPrompt(await ctx.systemPrompt.assemble())) .toBe('You are a helpful software engineer assistant.') @@ -738,7 +736,6 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: false, includeRuntimeContext: false, persona: 'You are merged.', - personaComplete: true, toolOrder: ['zulu'], tools: { mode: 'native' as const }, dshHome: '/tmp/dsh-home', @@ -757,7 +754,6 @@ describe('dsh-agent-spine-demo bundle', () => { includeHarnessIdentity: appConfig.includeHarnessIdentity, includeRuntimeContext: appConfig.includeRuntimeContext, persona: appConfig.persona, - personaComplete: appConfig.personaComplete, toolOrder: appConfig.toolOrder, tools: appConfig.tools, dshHome: appConfig.dshHome, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index e038b24a0d..c9f2019f62 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:197', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:186', }, { key: 'conversation.chat.commandview', @@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:185', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:174', }, { key: 'conversation.chat.node', @@ -310,7 +310,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:166', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:155', }, { key: 'conversation.chat.turnTail', @@ -355,7 +355,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:191', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:180', }, { key: 'conversation.composer', @@ -404,7 +404,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer\', () => ctx.slots.register(\n { name: \'conversation.composer\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', }, { key: 'conversation.composer.bar', @@ -440,7 +440,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.bar\', () => ctx.slots.register(\n { name: \'conversation.composer.bar\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:110', }, { key: 'conversation.composer.dock', @@ -498,7 +498,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.composer.dock\', () => ctx.slots.register(\n { name: \'conversation.composer.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', }, { key: 'conversation.details.tool', @@ -534,7 +534,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:203', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:192', }, { key: 'conversation.hero.agentPreset', @@ -562,7 +562,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.agentPreset\', () => ctx.slots.register(\n { name: \'conversation.hero.agentPreset\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', }, { key: 'conversation.hero.brand.mark', @@ -590,7 +590,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.brand.mark\', () => ctx.slots.register(\n { name: \'conversation.hero.brand.mark\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:82', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:96', }, { key: 'conversation.hero.workspace', @@ -620,7 +620,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.hero.workspace\', () => ctx.slots.register(\n { name: \'conversation.hero.workspace\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:80', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', }, { key: 'conversation.hero.workspace.directoryFlow', @@ -686,7 +686,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.attachments\', () => ctx.slots.register(\n { name: \'conversation.input.attachments\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:98', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:112', }, { key: 'conversation.input.dock', @@ -746,7 +746,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.dock\', () => ctx.slots.register(\n { name: \'conversation.input.dock\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:86', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:100', }, { key: 'conversation.input.left', @@ -802,7 +802,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.left\', () => ctx.slots.register(\n { name: \'conversation.input.left\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:92', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', }, { key: 'conversation.input.model', @@ -838,7 +838,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.model\', () => ctx.slots.register(\n { name: \'conversation.input.model\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:106', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:120', }, { key: 'conversation.input.overlay', @@ -892,7 +892,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.overlay\', () => ctx.slots.register(\n { name: \'conversation.input.overlay\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:88', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:102', }, { key: 'conversation.input.plan', @@ -928,7 +928,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.plan\', () => ctx.slots.register(\n { name: \'conversation.input.plan\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:104', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:118', }, { key: 'conversation.input.right', @@ -984,7 +984,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.input.right\', () => ctx.slots.register(\n { name: \'conversation.input.right\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:94', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:108', }, { key: 'conversation.message.images', @@ -994,7 +994,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ doc: 'Renderer for one consecutive group of durable message images. The owner\nsupplies image references, an authorized loader, and alignment. A\nregistration replaces the shipped gallery; without one, images are omitted.', registerOptions: [], ownerProps: [ - '/** Historical image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n loadImage: (attachment: ImageAttachmentRef) => Promise\n align: \'start\' | \'end\'\n}', + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', ], ownerPropsReferences: [ 'ImageAttachmentRef', @@ -1022,7 +1022,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:179', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:168', }, { key: 'conversation.session', @@ -1056,7 +1056,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session\', () => ctx.slots.register(\n { name: \'conversation.session\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:54', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:68', }, { key: 'conversation.session.header', @@ -1090,7 +1090,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header\', () => ctx.slots.register(\n { name: \'conversation.session.header\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:56', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', }, { key: 'conversation.session.header.actions', @@ -1146,7 +1146,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.actions\', () => ctx.slots.register(\n { name: \'conversation.session.header.actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:64', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:78', }, { key: 'conversation.session.header.lineage', @@ -1184,7 +1184,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.lineage\', () => ctx.slots.register(\n { name: \'conversation.session.header.lineage\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:58', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:72', }, { key: 'conversation.session.header.utilities', @@ -1239,7 +1239,45 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.session.header.utilities\', () => ctx.slots.register(\n { name: \'conversation.session.header.utilities\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:70', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:84', + }, + { + key: 'conversation.trajectory.images', + kind: 'single', + scope: 'session', + summary: 'Renderer for one group of durable record images in the Trajectory ledger.', + doc: 'Renderer for one group of durable record images in the Trajectory\nledger. The owner supplies image references, an authorized loader, and\nalignment. A registration replaces the shipped gallery; without one,\nimages are omitted.', + registerOptions: [], + ownerProps: [ + '/** Durable image group handed to the optional attachment presentation plugin. */\nexport interface MessageImagesOwnerProps {\n /** Durable image references in source order. */\n images: readonly { readonly attachment: ImageAttachmentRef }[]\n /** Session-authorized image URL loader. */\n loadImage: (attachment: ImageAttachmentRef) => Promise\n /** Horizontal placement inside the owning record. */\n align: \'start\' | \'end\'\n}', + ], + ownerPropsReferences: [ + 'ImageAttachmentRef', + ], + standardProps: [ + 'useWorkspaces: SnapshotSelectorHook', + 'useSessions: UseSessions', + 'useSessionPendingInteraction: UseSessionPendingInteraction', + 'useWorkspaces: SnapshotSelectorHook', + 'useChat: UseChat', + 'useConversation: UseConversation', + 'useInput: SnapshotSelectorHook', + 'inputActions: InputActions', + 'useSession: SessionSnapshotSelector', + 'sessionId: SessionId', + 'useProjection: UseProjection', + 'useTrajectory: UseTrajectory', + ], + keyDomain: '', + hookContext: '', + slotInject: '', + declaredBy: 'an entry in \'conversation.view\' (client-ui-trajectory), so it exists while that entry is mounted', + occupants: [ + 'client-ui-attachment MessageImages', + ], + replaceRisk: 'shadows-shipped-ui', + example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.trajectory.images\', () => ctx.slots.register(\n { name: \'conversation.trajectory.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', + source: 'packages/client/ui-trajectory/src/client/trajectory-contract.ts:95', }, { key: 'conversation.view', @@ -1297,7 +1335,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.view\', () => ctx.slots.register(\n { name: \'conversation.view\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-conversation/src/client/contract/slots.ts:76', + source: 'packages/client/ui-conversation/src/client/contract/slots.ts:90', }, { key: 'details', diff --git a/packages/sdk/server/README.i18n.yaml b/packages/sdk/server/README.i18n.yaml index 957bd99306..b44e84412e 100644 --- a/packages/sdk/server/README.i18n.yaml +++ b/packages/sdk/server/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/sdk/server/README.md -README.md: e6d36df7e387e895941c082226ac955c69f16ca4 -README.zh.md: 13c026edc491ebdbec298b8c9cd330b03d675db7 +README.md: f10c45e2df06383d6726f249bb6db2015a7fb418 +README.zh.md: 7bf3bcdab2a318fbfcba7b5a6c3a60ff883f47b6 diff --git a/packages/sdk/server/README.md b/packages/sdk/server/README.md index e6d36df7e3..f10c45e2df 100644 --- a/packages/sdk/server/README.md +++ b/packages/sdk/server/README.md @@ -10,7 +10,7 @@ The `jsonrpc` plugin serves newline-delimited JSON-RPC over stdio so out-of-proc ## Config -`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. Optional `toolFilter.allow` and `toolFilter.deny` restrict each SDK-created root agent through `ctx.tools.restrict()`. An allow list excludes later global tool registrations that it does not name, so a fixed SDK deployment cannot silently gain model-facing tools when its base bundle expands. Unknown names and an empty filter fail when the first session is created. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. +`maxTokensAsSuccess` defaults to `false` and affects only the deployment-mapped status on `subagent.finished`; root-session prompts have no prompt-level status. The profile composition owns each root agent's tools. `JsonRpcConfig.input`, `output`, and `exit` are runtime-only transport hooks; production uses process stdio and `process.exit`. ## stdout is the protocol @@ -30,7 +30,7 @@ The plugin answers `shutdown`, flushes the response, disposes the root context s #### What the model sees -For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. A configured `toolFilter` projects that composition's global tool registry before the request is assembled and executed. +For each accepted `session/prompt`, text and durable content references enter one user message verbatim. Inline `SdkEncodedImageBlock` values are validated and committed through the composition's attachment store first, so the session log retains content-addressed image references rather than base64 bytes. This package adds no system-prompt prose or tool schema; those come from the other plugins in the composition. #### Token effect diff --git a/packages/sdk/server/README.zh.md b/packages/sdk/server/README.zh.md index 13c026edc4..7bf3bcdab2 100644 --- a/packages/sdk/server/README.zh.md +++ b/packages/sdk/server/README.zh.md @@ -10,7 +10,7 @@ ## 配置 -`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。可选的 `toolFilter.allow` 与 `toolFilter.deny` 通过 `ctx.tools.restrict()` 限制每个由 SDK 创建的根 agent。Allow 列表会排除之后出现但未指名的全局工具,因此固定的 SDK 部署不会在基础 bundle 扩展时静默获得面向模型的新工具。未知名称与空筛选器会在创建首个会话时明确失败。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 +`maxTokensAsSuccess` 默认为 `false`,且只影响 `subagent.finished` 上由部署映射的状态;根会话提示词没有提示词级状态。Profile 组合负责每个根 agent 的工具。`JsonRpcConfig.input`、`output` 和 `exit` 是仅供运行时使用的传输钩子;生产环境使用进程 stdio 和 `process.exit`。 ## stdout 即协议 @@ -30,7 +30,7 @@ Stdout 只承载 JSON-RPC 帧。部署不得组合 stdout logger;诊断应写 #### 模型看到的内容 -对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。配置的 `toolFilter` 会在请求组装与执行前投影该组合的全局工具注册表。 +对于每个已接受的 `session/prompt`,文本和持久内容引用会原样进入一条用户消息。内联 `SdkEncodedImageBlock` 会先通过组合中的附件存储完成校验与提交,因此会话日志保留内容寻址的图片引用而不是 base64 字节。此包不会添加系统提示词文本或工具 schema;这些内容来自组合中的其他插件。 #### Token 影响 diff --git a/packages/sdk/server/src/index.ts b/packages/sdk/server/src/index.ts index 17e9d3a89f..963b4fb3bd 100644 --- a/packages/sdk/server/src/index.ts +++ b/packages/sdk/server/src/index.ts @@ -25,13 +25,6 @@ export const inject = ['agents'] export interface JsonRpcConfig { /** Report max-token turn/subagent termination as a successful SDK result. */ maxTokensAsSuccess?: boolean - /** Per-root-agent model-facing tool filter; an allow list excludes later unnamed global tools. */ - toolFilter?: { - /** Global tool names that remain visible. */ - allow?: string[] - /** Global tool names removed from visibility. */ - deny?: string[] - } /** Transport input override; production uses `process.stdin`. */ input?: Readable /** Transport output override; production uses `process.stdout`. */ @@ -42,11 +35,6 @@ export interface JsonRpcConfig { export const Config: Schema = Schema.object({ maxTokensAsSuccess: Schema.boolean().default(false), - // Preserve omission; Schemastery's materialized empty object is not a valid restriction. - toolFilter: Schema.object({ - allow: Schema.array(Schema.string()).default(undefined as unknown as string[]), - deny: Schema.array(Schema.string()).default(undefined as unknown as string[]), - }).default(undefined as unknown as { allow: string[]; deny: string[] }), }) /** @@ -71,7 +59,6 @@ export function apply(ctx: Context, config: JsonRpcConfig): void { const transport = new JsonRpcLineTransport(input, output) const server = new HarnessSdkJsonRpcServer(ctx, transport, { maxTokensAsSuccess: resolvedConfig.maxTokensAsSuccess, - ...resolvedConfig.toolFilter === undefined ? {} : { toolFilter: resolvedConfig.toolFilter }, }) // Share one exit task so racing shutdown requests cannot dispose the root or diff --git a/packages/sdk/server/src/server.ts b/packages/sdk/server/src/server.ts index 14b996f217..f07b238755 100644 --- a/packages/sdk/server/src/server.ts +++ b/packages/sdk/server/src/server.ts @@ -15,7 +15,6 @@ import { carrierKeyOf, type Scoped } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import type SubagentRuntime from '@deepseek-ai/dsh-subagent' import type { SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' -import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import type { InitializeParams, @@ -61,8 +60,6 @@ function subagentParentOf(carrier: Scoped): Agent { export interface HarnessSdkJsonRpcServerOptions { /** Report max-token termination as an accepted result instead of an infrastructure error. */ maxTokensAsSuccess?: boolean - /** Restrict each SDK-created root agent to an explicit subset of global tools. */ - toolFilter?: ToolRestriction } function successStatus(reason: string, options: HarnessSdkJsonRpcServerOptions): 'ok' | 'error' { @@ -256,7 +253,6 @@ export class HarnessSdkJsonRpcServer { // rows in the host plane, so this agent reads them from the global layer. A // deployment that configures a roster has to join one here first // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). - const toolFilter = this.options.toolFilter const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, @@ -265,9 +261,6 @@ export class HarnessSdkJsonRpcServer { model: this.model, ...this.maxTokens === undefined ? {} : { maxTokens: this.maxTokens }, }, - ...toolFilter === undefined - ? {} - : { setup: (agentCtx: Context) => { agentCtx.tools.restrict(toolFilter) } }, }) const rec: SessionRecord = { handle } this.sessions.set(sessionId, rec) diff --git a/packages/sdk/server/tests/plugin-apply.spec.ts b/packages/sdk/server/tests/plugin-apply.spec.ts index 41954007eb..c5954e8dac 100644 --- a/packages/sdk/server/tests/plugin-apply.spec.ts +++ b/packages/sdk/server/tests/plugin-apply.spec.ts @@ -11,7 +11,6 @@ import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import { LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { defineTool } from '@deepseek-ai/dsh-tools' import * as jsonrpc from '../src/index.ts' /** @@ -72,7 +71,6 @@ async function mountPlugin( writeDelayMs?: number failFlush?: boolean beforeServer?: (ctx: Context) => Promise | void - toolFilter?: jsonrpc.JsonRpcConfig['toolFilter'] } = {}, ): Promise { const ctx = new Context() @@ -122,7 +120,6 @@ async function mountPlugin( input, output, exit, - ...options.toolFilter === undefined ? {} : { toolFilter: options.toolFilter }, }) const frames = (): Record[] => @@ -289,51 +286,6 @@ describe('dsh-sdk-jsonrpc-server plugin apply', () => { } }) - it('applies the configured root-agent tool filter through the Loader plugin', async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-tool-filter-')) - const llmServer = await mockCompletionServer() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) - const harness = await mountPlugin(storageDir, { - toolFilter: { allow: ['kept'] }, - beforeServer: (ctx) => { - for (const name of ['kept', 'excluded']) { - ctx.tools.register(defineTool({ - name, - description: name, - parameters: {}, - output: { - schema: { type: 'string' }, - render: (_args, value) => [{ type: 'text', text: value }], - }, - execute: async () => name, - })) - } - }, - }) - try { - harness.send({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' } }) - await harness.waitForFrame(frame => frame.id === 1, 'initialize response') - harness.send({ - jsonrpc: '2.0', - id: 2, - method: 'session/prompt', - params: { sessionId: 'filtered', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, - }) - await harness.waitForFrame( - frame => frame.method === 'session.status' - && (frame.params as { status?: string } | undefined)?.status === 'idle', - 'filtered session idle status', - ) - - const request = llmServer.requests[0] as { tools?: Array<{ function?: { name?: string } }> } - expect(request.tools?.map(entry => entry.function?.name)).toEqual(['kept']) - } finally { - await harness.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - it('answers shutdown before exiting 0 exactly once, even against a racing second shutdown', async () => { const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-apply-shutdown-')) const harness = await mountPlugin(storageDir, { writeDelayMs: 10 }) diff --git a/packages/sdk/server/tests/server.spec.ts b/packages/sdk/server/tests/server.spec.ts index 471f794f27..9707417e32 100644 --- a/packages/sdk/server/tests/server.spec.ts +++ b/packages/sdk/server/tests/server.spec.ts @@ -14,7 +14,6 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SubagentRuntime, { type SubagentResult, type SubagentRunEndInfo } from '@deepseek-ai/dsh-subagent' import type { JsonRpcTransportPeer } from '@deepseek-ai/dsh-sdk-protocol' -import { defineTool } from '@deepseek-ai/dsh-tools' import { HarnessSdkJsonRpcServer } from '../src/index.ts' class FakeTransport implements JsonRpcTransportPeer { @@ -172,46 +171,6 @@ describe('HarnessSdkJsonRpcServer', () => { } }) - it('allowlists each root session against current and later global tools', { timeout: 15_000 }, async () => { - const storageDir = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-tool-filter-')) - const llmServer = await mockCompletionServer() - vi.stubEnv('DEEPSEEK_API_KEY', 'test-key') - vi.stubEnv('DEEPSEEK_BASE_URL', llmServer.url) - const ctx = await makeHarness(storageDir) - const tool = (name: string) => defineTool({ - name, - description: name, - parameters: {}, - output: { - schema: { type: 'string' as const }, - render: (_args, value) => [{ type: 'text' as const, text: value }], - }, - execute: async () => name, - }) - ctx.tools.register(tool('kept')) - ctx.tools.register(tool('excluded')) - const server = new HarnessSdkJsonRpcServer(ctx, new FakeTransport(), { - toolFilter: { allow: ['kept'] }, - }) - try { - await server.initialize({ cwd: storageDir, provider: 'deepseek-official', model: 'filtered-model' }) - await server.prompt({ sessionId: 'first', contentBlocks: [{ type: 'text', text: 'first' }] }) - await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(1) }) - ctx.tools.register(tool('future')) - await server.prompt({ sessionId: 'second', contentBlocks: [{ type: 'text', text: 'second' }] }) - await vi.waitFor(() => { expect(llmServer.requests).toHaveLength(2) }) - - expect(llmServer.requests.map((request) => { - const tools = (request as { tools?: Array<{ function?: { name?: string } }> }).tools ?? [] - return tools.map(entry => entry.function?.name) - })).toEqual([['kept'], ['kept']]) - await server.shutdown() - } finally { - await ctx.fiber.dispose() - await rm(storageDir, { recursive: true, force: true }) - } - }) - it('queues overlapping prompts for one session without blocking other sessions', async () => { const mainFollowup = vi.fn() const mainAgent = ({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e75d16a06..0cdfcec7c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1835,6 +1835,9 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-client-ui-trajectory': + specifier: workspace:^ + version: link:../ui-trajectory '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../runtime-diagnostics/invariants @@ -1961,9 +1964,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - '@deepseek-ai/dsh-util-crypto': - specifier: workspace:^ - version: link:../../util/crypto '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path @@ -3379,6 +3379,9 @@ importers: '@deepseek-ai/dsh-api-session-controller': specifier: workspace:^ version: link:../../api/session-controller + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale diff --git a/python/sdk/src/deepseek_harness/client.py b/python/sdk/src/deepseek_harness/client.py index 804076636d..9b64980cbb 100644 --- a/python/sdk/src/deepseek_harness/client.py +++ b/python/sdk/src/deepseek_harness/client.py @@ -34,7 +34,6 @@ class HarnessConfig: initialize_timeout_seconds: float = 30.0 request_timeout_seconds: float | None = None shutdown_timeout_seconds: float | None = 1.0 - _launch_args: tuple[str, ...] | None = None class HarnessClient: @@ -47,7 +46,7 @@ class HarnessClient: _launch_args: tuple[str, ...] | None = None, ) -> None: self.config = config or HarnessConfig() - self._launch_args = _launch_args or self.config._launch_args + self._launch_args = _launch_args self._proc: subprocess.Popen[str] | None = None self._lock = threading.Lock() self._write_lock = threading.Lock() diff --git a/python/sdk/tests/test_client.py b/python/sdk/tests/test_client.py index d5ed7dada8..639f3a0b7d 100644 --- a/python/sdk/tests/test_client.py +++ b/python/sdk/tests/test_client.py @@ -471,9 +471,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -611,7 +609,7 @@ for line in sys.stdin: def broken_filter(_notification: object) -> bool: raise RuntimeError("bad notification filter") - with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with ( client.subscribe_notifications(broken_filter) as broken, @@ -648,7 +646,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") with pytest.raises(ValueError): client.session_prompt("main", [{"type": "text", "text": "fix it"}]) @@ -675,9 +673,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") request = client.next_request() @@ -709,9 +705,7 @@ for line in sys.stdin: """.strip() ) - with HarnessClient( - HarnessConfig(_launch_args=(sys.executable, str(script))) - ) as client: + with HarnessClient(_launch_args=(sys.executable, str(script))) as client: init = client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") assert init.serverInfo.name == "fake-dsh" @@ -730,10 +724,10 @@ time.sleep(60) with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), profile="web", initialize_timeout_seconds=0.1, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: start = time.monotonic() try: @@ -768,9 +762,9 @@ for line in sys.stdin: client = HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), shutdown_timeout_seconds=0.1, - ) + ), + _launch_args=(sys.executable, str(script)), ) client.start() proc = client._proc @@ -808,10 +802,10 @@ Path(os.environ["QUIESCED_MARKER"]).write_text("quiesced") client = HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), env={"QUIESCED_MARKER": str(marker)}, shutdown_timeout_seconds=1, - ) + ), + _launch_args=(sys.executable, str(script)), ) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -838,7 +832,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) + client = HarnessClient(_launch_args=(sys.executable, str(script))) client.start() proc = client._proc assert proc is not None @@ -878,6 +872,7 @@ def test_public_signatures_omit_unsupported_wire_parameters() -> None: for removed in ("cordis", "session_root", "runtime_bin", "bridge_bin", "launch_args_override"): assert removed not in DeepSeekHarnessConfig.__dataclass_fields__ assert removed not in HarnessConfig.__dataclass_fields__ + assert "_launch_args" not in HarnessConfig.__dataclass_fields__ assert "session_root" not in RunResult.__dataclass_fields__ @@ -900,7 +895,7 @@ for line in sys.stdin: """.strip() ) - client = HarnessClient(HarnessConfig(_launch_args=(sys.executable, str(script)))) + client = HarnessClient(_launch_args=(sys.executable, str(script))) client.start() client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") client.close() @@ -920,9 +915,9 @@ sys.exit(42) with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), request_timeout_seconds=2, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: with pytest.raises(Exception, match="fatal bridge exploded"): client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") @@ -952,9 +947,9 @@ with open(os.environ["SEEN"], "w") as seen: with HarnessClient( HarnessConfig( - _launch_args=(sys.executable, str(script)), env={"SEEN": str(output)}, - ) + ), + _launch_args=(sys.executable, str(script)), ) as client: client.initialize(provider="deepseek-official", cwd="/workspace", model="dsagent") threads = [ diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index b28e740495..b905c8ac5f 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -221,6 +221,36 @@ def write_profile_patch( return path +def write_advanced_profile_patch(root: Path, name: str, sessions: Path) -> Path: + """Write the shared custom, snapshot, and restart profile patch.""" + return write_profile_patch(root, name, sessions, [ + {"id": "tools", "config": {"mode": "both"}}, + { + "id": "system-prompt", + "config": { + "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", + }, + }, + {"id": "session-log-deepseek", "config": {"enabled": True}}, + *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), + {"id": "tool-bash", "disabled": True}, + {"id": "tool-pwsh", "disabled": True}, + { + "id": "tool-subagent", + "config": { + "provider": "spawn", + "toolName": "subagent", + "backgroundMode": "one-shot", + }, + }, + {"insert": [ + {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, + {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, + {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, + ]}, + ]) + + def write_mcp_patch(root: Path, sessions: Path, server_script: Path) -> Path: """Write a profile patch that mounts the packaged MCP client.""" return write_profile_patch(root, "mcp.patch.yml", sessions, [{ @@ -923,32 +953,7 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "custom.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "custom.patch.yml", sessions) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", @@ -1173,32 +1178,7 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "snapshot.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "snapshot.patch.yml", sessions) with DeepSeekHarness( provider="deepseek-official", model="smoke-model", @@ -1247,32 +1227,7 @@ def smoke_sdk_restart_snapshot(base_url: str, executable: Path, update_snapshots root = Path(temporary).resolve() dsh_home = root / "home" sessions = dsh_home / "sessions" - patch = write_profile_patch(root, "restart.patch.yml", sessions, [ - {"id": "tools", "config": {"mode": "both"}}, - { - "id": "system-prompt", - "config": { - "persona": "You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.", - }, - }, - {"id": "session-log-deepseek", "config": {"enabled": True}}, - *({"id": row_id, "disabled": True} for row_id in LEGACY_CUSTOM_DISABLED_ROWS), - {"id": "tool-bash", "disabled": True}, - {"id": "tool-pwsh", "disabled": True}, - { - "id": "tool-subagent", - "config": { - "provider": "spawn", - "toolName": "subagent", - "backgroundMode": "one-shot", - }, - }, - {"insert": [ - {"id": "code-runtime", "name": "@deepseek-ai/dsh-code-runtime-worker-thread"}, - {"id": "cordis-host-runner", "name": "@deepseek-ai/dsh-cordis-host-runner"}, - {"id": "cordis-tool", "name": "@deepseek-ai/dsh-tool-cordis"}, - ]}, - ]) + patch = write_advanced_profile_patch(root, "restart.patch.yml", sessions) first_request = len(MockModelHandler.requests) def run(prompt: str, session_id: str) -> "RunResult":