diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 21c6e32e11..9e32647a60 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 12482ca80d19e5cd1e62b8860865db6cccea61ab -2026-06-21-mandatory-app-attribution-headers.zh.md: 43f356887948b88d34ba41d46ed1cd2f1c89a1e2 +2026-06-21-mandatory-app-attribution-headers.md: 39050a53ec76e8c5a6cac4d8e31fa15b992c406e +2026-06-21-mandatory-app-attribution-headers.zh.md: bfd6aa2540022f68cf9f69ccc2c12b3bc0978960 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 12482ca80d..39050a53ec 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release +- app URL: `https://github.com/deepseek-ai/deepseek-harness` - the repository home The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override hook is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,8 +77,6 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. - **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. **OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 43f3568879..bfd6aa2540 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness`——仓库主页 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖钩子就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,8 +77,6 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 - **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 约定。 **OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 327eeef2fb..98319ccd48 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: ff411e1b387dcd83b04f8c02d5dde3c70a815932 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 593e194654bb3552ad3cac4534847fba11cf3694 +2026-07-25-web-client-session-scope-and-provide-channel.md: f371b93ccf6cb3ba10cbdb73baa670cbf889f393 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 78440131c4e09c9a458009fbe5a2a34a707a481c diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index ff411e1b38..f371b93ccf 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). @@ -132,5 +132,5 @@ Slot scope is the closed set `root | session-maybe | session`: - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests; fully disabled input while no workspace is picked is an experience cost the product surface accepts (the price of the single state axis). +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident card routes activation to the Workspace picker during that interval ([decision](../feature/2026-08-07-workspace-picker-composer-entry.md)). - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 593e194654..78440131c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine face 和消息操作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 @@ -132,5 +132,5 @@ slot scope 是闭集 `root | session-maybe | session`: - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,常驻卡片会把激活操作转到 Workspace picker([决策](../feature/2026-08-07-workspace-picker-composer-entry.md))。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml new file mode 100644 index 0000000000..0d52923b6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md +2026-08-07-workspace-picker-composer-entry.md: dc9c26c291de6e7614ace3c787030c0032a9740d +2026-08-07-workspace-picker-composer-entry.zh.md: 585c59b12392823a7388ab7a635a864bd4108929 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md new file mode 100644 index 0000000000..dc9c26c291 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md @@ -0,0 +1,29 @@ +# Agent Note: The no-Workspace composer opens the existing picker + +Status: implemented + +English | [中文](2026-08-07-workspace-picker-composer-entry.zh.md) + +## Problem + +The [session-scope decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) keeps one resident composer before a Workspace exists, but its textarea was disabled and only the smaller Workspace chip could open the picker. The largest and most familiar starting affordance therefore rejected the user's first click even though a recovery action was available on the same surface. + +## Decision + +While no Workspace owns the new Session, the whole composer card activates the existing `conversation.hero.workspace` picker by pointer click — the card owns the click handler and its disabled controls let pointer events fall through, so the full capsule is one target — and the read-only resident textarea does the same by Enter or Space. `aria-haspopup="menu"` and `aria-expanded` describe the shared picker menu while it is mounted. On a fresh installation with no Workspace rows, the picker immediately hands off to the directory dialog and clears its expanded state; that dialog exposes its own accessibility semantics. A dashed l4 stroke (an SVG dash ring, since native `dashed` has a fixed pattern) with a business-blue hover marks the card as the pick affordance. The card contains `pointerdown`, so the open picker's outside-close cannot race the click's reopen — that close-then-open flickered the chip's expansion echo. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. + +Workspace selection retains the existing owner and flow. `ConversationRoot` opens the picker, `WorkspacePicker` lists or creates the Workspace, and the same textarea DOM node becomes the editable composer after the Session arrives. + +## Alternatives considered + +**Keep the textarea disabled and emphasize the Workspace chip.** This preserves the old control boundary but leaves the dominant composer surface inert during the first action. + +**Place a transparent button over the textarea.** A button has direct trigger semantics, but it creates a second focusable element over the resident textarea and complicates the DOM-identity transition that preserves focus, IME, and draft behavior. + +**Accept a draft before Workspace selection.** This would require a client-owned draft Session or another pre-Session state axis. The feature only needs a discoverable path into the existing picker. + +## Consequences + +The first composer click now continues the required setup flow, and keyboard users can activate the same path. The textarea accurately reports read-only state until a Session exists, while adjacent controls remain disabled. The UI introduces no new Workspace state, transport, or directory-selection flow. + +Component coverage pins pointer and keyboard activation, the card-wide click target, the contained `pointerdown`, locked adjacent controls, picker expansion, and the same-node transition to an editable textarea. The assembled Web helper begins fresh Workspace setup through the textarea, so replayed browser scenarios exercise the shipped path. diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md new file mode 100644 index 0000000000..585c59b123 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 未选择 Workspace 时从编辑器打开现有选择器 + +Status: implemented + +[English](2026-08-07-workspace-picker-composer-entry.md) | 中文 + +## 问题 + +[Session scope 决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md)会在 Workspace 存在前保留同一个常驻编辑器,但 textarea 处于禁用状态,只有较小的 Workspace chip 能打开选择器。用户首次点击最显眼、也最熟悉的输入区域时,界面不会响应,尽管同一界面已有继续操作的入口。 + +## 决策 + +新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活。`aria-haspopup="menu"` 和 `aria-expanded` 在共享选择器菜单挂载时描述其展开状态。全新安装没有 Workspace 行时,选择器会立即转交目录对话框并清除自身的展开状态;该对话框使用自己的可访问性语义。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 + +Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 + +## 考虑过的替代方案 + +**保持 textarea 禁用并突出 Workspace chip。** 这样能保留原有控件边界,但首次操作时最主要的编辑器区域仍然没有响应。 + +**在 textarea 上方放置透明按钮。** 按钮具备直接的触发器语义,但它会在常驻 textarea 上方增加第二个可聚焦元素,并使保留焦点、输入法和草稿行为的 DOM identity 过渡更复杂。 + +**在选择 Workspace 前接收草稿。** 这需要由 client 拥有的草稿 Session 或另一条 Session 前状态轴。此功能只需要提供一个更容易发现的现有选择器入口。 + +## 后果 + +用户首次点击编辑器即可继续必要的设置流程,键盘用户也能激活同一路径。textarea 会如实报告只读状态,直到 Session 存在;相邻控件仍处于禁用状态。界面没有引入新的 Workspace 状态、传输或目录选择流程。 + +组件测试会固定鼠标和键盘激活、覆盖整卡的点击目标、被拦下的 `pointerdown`、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml new file mode 100644 index 0000000000..9984adfa0f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.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-08-web-background-task-display.md +2026-08-08-web-background-task-display.md: 558d0e26b3b8da83602296c5fdd0944886d4eab7 +2026-08-08-web-background-task-display.zh.md: 9c9f4643bb08c748381ede6bf585858ca1204ff7 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md new file mode 100644 index 0000000000..558d0e26b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md @@ -0,0 +1,136 @@ +# Agent Note: Web background-task display + +Status: implemented + +English | [中文](2026-08-08-web-background-task-display.zh.md) + +## Problem + +`ctx.tasks` already runs every long-lived piece of work the harness starts in the background — `bash`, `pwsh`, `pty-send`, and one-shot background subagents — but its only reader was the model. [`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) exposes `task_list`, `task_output`, and `task_kill`, and nothing else observed the registry. + +A human at the Web client therefore could not see that a build was running, could not distinguish a finished task from a stuck one, and could not stop one. The only trace was the `run_in_background` tool card that printed a task id somewhere earlier in the transcript, and that card never updates again. + +The session header was already the place where per-session background activity lives: [`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) contributes the subagent catalog to `conversation.session.header.actions`. Placement was settled. What was missing was any channel at all that carried task state to a browser. + +## Decision + +Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. + +This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. + +### Wire shape + +One frame in the mux stream: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts), alongside the other domain contracts, with its wire schema beside it in `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` comes from the cordis-free [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) leaf — the same arrangement as the `@deepseek-ai/dsh-llm/brand` import `api/subagents.ts` already uses, because the `dsh-tasks` root reaches `dsh-agent` and is unreachable from a client program even as a type. Like every other non-root subpath in this workspace, it carries an explicit `tsconfig.base.json` `paths` entry; without one the TypeRT analyzer resolves the specifier to `lib/types/` and rejects the reference as unexported. + +`kind` is `string` on the wire rather than `TaskKind`. The kind map is merge-extensible by producer plugins, so a client build cannot enumerate the closed set; presentation falls through a documented default for an unrecognized kind. + +Three `TaskSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). + +The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. + +### The task-registry change feed + +`TaskService` owns one observation method: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +It fires **after** every commit that changes what `list(owner)` returns: registration at the end of `start()`, the `stopping` transition in `kill()`, settlement, and the removal `disposeOwner()` performs. An `undefined` owner means an unowned task changed, and therefore every caller's view changed. + +The listener is owner-granular rather than task-granular. The only consumer pushes whole snapshots, so a per-task record would be discarded on arrival — and a per-task feed cannot express the owner-disposal removal at all without inventing a tombstone status nothing else needs. + +`onTaskDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-tasks` couples to `reported`; `onTasksChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onTaskDone`, and each registration is an effect on the calling fiber. + +Service disposal deliberately announces nothing. Every `onTasksChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. + +### The api-proxy carrier + +`mux()` subscribes `ctx.tasks.onTasksChanged` and pushes `session/tasks`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. + +Four rules the carrier keeps: + +- **Never resume.** A change push reads `tasks.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.tasks.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('tasks')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. +- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. + +### The client mirror + +`SessionListState` carries `tasksBySession: Readonly>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation. + +It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. + +Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. + +### The header action + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. + +A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. + +### What this deliberately does not do + +**No web path calls `ctx.tasks.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `task_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site. + +**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against today's contract would leave the model believing its task is still running. + +**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader. + +## Alternatives considered + +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `tasks-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. + +**Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `TaskService`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. + +**A session-projection unit over durable task events.** Projection units fold over committed session events, so this would first require task lifecycle to become durable — `task/started` … `task/settled` as a standalone open/close bracket, with the last [`session/end-seed`](../../../../packages/core/session/src/types.ts) marking any unmatched opener as dead history, exactly as the compaction bracket already does. It is genuinely cheaper on the client: `dsh-tool-todo` shows the whole pattern in a fifteen-line unit, and the existing `session/projection` frames, history-tail block, and persisted checkpoint cache would have carried the data with no new wire surface, no carrier subscription, and no manager state. It was rejected because it buys that with a durable format change in service of a browser list, and because it does not extend to the phase it would most need to: [`spill/`](../../../../packages/spill/README.md) exists precisely so oversized tool output stays out of the log, so streamed task output cannot ride durable events either way. Nothing here forecloses revisiting it if durable task history becomes valuable on its own merits. + +**Reusing `PublicTaskSnapshot` from `dsh-tool-tasks`.** Nearly the right fields, but it belongs to the model-facing control surface. A wire type a browser program imports from a tool package couples client presentation to prompt-facing decisions and drags a host-only package into a client build. + +**Folding tasks into the subagent catalog as one "activity" panel.** One entry point instead of two. Rejected because `SubagentCatalogAction` is already 605 lines whose subject is a durable session-lineage tree including finished children; process-scoped tasks are a second data model with different identity, lifetime, and affordances, and the catalog's lazily-expanded branch, duration, and token contracts would all need rewriting to host them. + +**A host-global task list across every session.** The literal reading of "show all running tasks". Rejected because the registry's authorization fence is per-owner-session, so a global read needs a new access rule, and a global list has no business in a session's header — it would need its own home in the sidebar. Nothing in this design blocks adding it later; the per-session frames are the same data. + +## Testing + +The [web e2e scenario](../../../../apps/web/tests/background-task-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.tasks`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. + +Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. + +## Consequences + +**A missed commit point leaks rows.** If `disposeOwner()` removal ever stops firing the feed, the client keeps tasks that no longer exist until the session disappears. The whole-snapshot shape makes this recoverable rather than corrupting — the next legitimate change repairs the list — but the disposal path is the one most easily forgotten, so it carries its own test. + +**Unowned-task fan-out is easy to under-implement.** Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly. + +**The UI set is not the registry's set.** The header shows what one session can see, so a task owned by another session never appears in it even though the registry holds it — and because the registry is process-local, a restart empties every list while the transcript still shows the `run_in_background` cards that started them. Unowned tasks are the opposite case: they reach every session's list, exactly as `list(caller)` reports them to every caller. + +**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. + +**`stopping` is nearly unreachable today.** Only the model's `task_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. + +**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind. + +**A new non-root subpath needs its `paths` entry.** `@deepseek-ai/dsh-tasks/brand` had to be registered in `tsconfig.base.json` before the TypeRT analyzer would accept the reference. The failure mode is a confusing "not exported by" error from a generator far from the edit, so the entry is part of adding a subpath, not an optimization. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md new file mode 100644 index 0000000000..9c9f4643bb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md @@ -0,0 +1,136 @@ +# Agent Note: Web 后台任务展示 + +Status: implemented + +[English](2026-08-08-web-background-task-display.md) | 中文 + +## 问题 + +`ctx.tasks` 已经承载了 harness 在后台启动的全部长时工作——`bash`、`pwsh`、`pty-send`,以及一次性后台 subagent——但它唯一的读者是模型。[`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) 暴露了 `task_list`、`task_output` 和 `task_kill`,除此之外没有任何东西观察这个注册表。 + +于是 Web 端的人类看不到构建正在跑,分不清一个任务是已经完成还是卡死,也无法把它停掉。唯一的痕迹是 transcript 里更早某处那张打印了 task id 的 `run_in_background` 工具卡片,而那张卡片此后再也不会更新。 + +会话 header 本来就是每会话后台活动的落点:[`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) 把 subagent 目录贡献到 `conversation.session.header.actions`。位置没有争议。缺的是任何一条把任务状态送到浏览器的通道。 + +## 决策 + +任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 + +本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 + +### 线路形状 + +mux 流中的一帧: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` 取自不依赖 cordis 的 [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) 叶子——与 `api/subagents.ts` 已经在用的 `@deepseek-ai/dsh-llm/brand` 导入是同一种安排,因为 `dsh-tasks` 根出口会牵到 `dsh-agent`,即便只作类型也无法被客户端程序触及。和本仓库其他每一个非根子路径一样,它带有显式的 `tsconfig.base.json` `paths` 条目;没有这一条,TypeRT 分析器会把该 specifier 解析到 `lib/types/` 并判定该引用未被导出。 + +线路上的 `kind` 是 `string` 而非 `TaskKind`。kind 映射由生产者插件按声明合并扩展,客户端构建无法枚举这个闭集;遇到无法识别的 kind,呈现层走一条有文档的默认分支。 + +`TaskSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 + +这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 + +### 任务注册表变更订阅 + +`TaskService` 拥有一个观察方法: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +它在每一个会改变 `list(owner)` 返回内容的提交点**之后**触发:`start()` 末尾的注册、`kill()` 里转入 `stopping`、结算,以及 `disposeOwner()` 执行的移除。`owner` 为 `undefined` 表示一个无主任务发生了变化,因而每一个调用方的视图都变了。 + +监听器按 owner 而非按任务分粒度。唯一的消费方推的是整份快照,逐任务记录到手即弃——而且逐任务的订阅根本无法表达 owner 销毁时的移除,除非发明一个别处都不需要的墓碑状态。 + +`onTaskDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-tasks` 把这套语义与 `reported` 绑在一起;`onTasksChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onTaskDone` 一致,每次注册都是调用方 fiber 上的 effect。 + +服务销毁刻意什么都不通告。每个 `onTasksChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 + +### api-proxy 载体 + +`mux()` 订阅 `ctx.tasks.onTasksChanged` 并推送 `session/tasks`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 + +载体守着四条规则: + +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `tasks.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.tasks.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('tasks')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 +- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 + +### 客户端镜像 + +`SessionListState` 带有 `tasksBySession: Readonly>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。 + +它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 + +两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 + +### header 入口 + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 + +因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 + +### 刻意不做的事 + +**没有任何 Web 路径调用 `ctx.tasks.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `task_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。 + +**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。 + +**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。 + +## 备选方案 + +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `tasks-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 + +**只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `TaskService` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 + +**基于持久任务事件的 session-projection 单元。** 投影单元在已提交的会话事件上折叠,所以这条路要先让任务生命周期变持久——`task/started` … `task/settled` 作为一对独立的开合括号,由最后一个 [`session/end-seed`](../../../../packages/core/session/src/types.ts) 把未配对的开括号标为死历史,与 compaction 括号已有的做法完全一致。它在客户端确实更省:`dsh-tool-todo` 用十五行的单元展示了整套模式,而现成的 `session/projection` 帧、history-tail 块和持久化 checkpoint 缓存本可以承载这批数据,无需新线路面、无需载体订阅、无需 manager 状态。否决它,是因为这要拿一次持久格式变更去换一个浏览器列表,而且它并不能延伸到最需要它的那一期:[`spill/`](../../../../packages/spill/README.md) 的存在正是为了让超大工具输出留在日志之外,所以流式任务输出无论如何都不能骑在持久事件上。如果持久任务历史将来凭自身价值站得住,本设计不阻挡重新考虑它。 + +**复用 `dsh-tool-tasks` 的 `PublicTaskSnapshot`。** 字段几乎就是对的,但它属于面向模型的控制面。浏览器程序从一个 tool 包导入线路类型,会把客户端呈现耦合到面向 prompt 的决策上,并把一个 host-only 包拖进客户端构建。 + +**并进 subagent 目录做成统一的「活动」面板。** 一个入口而不是两个。否决的理由是 `SubagentCatalogAction` 已经 605 行,其主题是含已结束子会话的持久会话血缘树;进程域的任务是第二套数据模型,身份、生命期和可用动作都不同,而目录的懒展开分支、时长与 token 契约全都要重写才能容纳它们。 + +**跨全部会话的 host 全局任务列表。**「显示所有运行中任务」的字面读法。否决是因为注册表的鉴权围栏是按 owner 会话的,全局读需要一条新的访问规则,而且全局列表不该出现在某个会话的 header 里——它需要侧栏里自己的位置。本设计没有阻挡后续再加;按会话的帧就是同一批数据。 + +## 测试 + +[web e2e 场景](../../../../apps/web/tests/background-task-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.tasks`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 + +在它之下,[`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 + +## 影响 + +**漏掉一个提交点会漏行。** 如果 `disposeOwner()` 的移除有朝一日不再触发订阅,客户端会一直留着已经不存在的任务,直到会话消失。整份快照的形状让这件事可恢复而非损坏——下一次正当变更就修好了——但销毁路径是最容易被忘掉的一条,所以它自带测试。 + +**无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 + +**UI 的集合不等于注册表的集合。** header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 `run_in_background` 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 `list(caller)` 对每个调用方都报告它们。 + +**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 + +**`stopping` 今天几乎不可达。** 只有模型的 `task_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 + +**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 + +**新增非根子路径必须补 `paths` 条目。** `@deepseek-ai/dsh-tasks/brand` 得先登记进 `tsconfig.base.json`,TypeRT 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 36f6181c46..56f184e831 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 -2026-07-13-documentation-site-projection.zh.md: 7d7b4752b8f27d55aae8426a7dc001ce4340e661 +2026-07-13-documentation-site-projection.md: 309dbd96c5f1ca87d137cdc3839acfc5c5aa22f2 +2026-07-13-documentation-site-projection.zh.md: b01965fb1b46ab618b5b8bcb748bb5a8940d6e69 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index d9af915754..309dbd96c5 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,9 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the `deepseek-ai/deepseek-harness` repository home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. -`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. +`verify-public-repository-links` rejects references to the unavailable legacy repository from tracked files. Source and edit links use the current repository home. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 7d7b4752b8..b01965fb1b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,9 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 `deepseek-ai/deepseek-harness` 仓库主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 -`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 +`verify-public-repository-links` 会拒绝已跟踪文件中指向不可用旧仓库的引用。源文件链接和编辑链接使用当前仓库主页。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts index 00d5278f82..28e418d6fc 100644 --- a/apps/cli/tests/dsh-badge.snapshot.ts +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: @@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts new file mode 100644 index 0000000000..734d43131a --- /dev/null +++ b/apps/web/tests/background-task-list.e2e.ts @@ -0,0 +1,133 @@ +// Web e2e scenario: the session-header background-task list over the real +// host. No model call is involved — a genuine `run_in_background` bash call +// registers with `ctx.tasks`, and the assertion chain is the whole delivery +// path: registry change feed → api-proxy `session/tasks` frame → the client's +// `tasksBySession` mirror → the header action. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url)) +const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'background-task-list-web-e2e' +// Long enough that the running assertions never race the process exiting on +// their own; the test kills it explicitly to reach the settled state. +const COMMAND = 'sleep 45' + +/** + * Wait for the Host to publish the live Agent that opening a session resumes. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: background task list', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let taskId: TaskId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // task owner must be that exact live instance, never a second one. + // `expect.poll` is test-scoped, so this hook polls by hand. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows a running background task in the session header without a refresh', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running')) + // Point assertion, not a poll: `expect.poll` retries until a predicate + // holds, so polling for zero passes at t=0 and proves nothing. The + // "renders nothing without a task" branch is owned by the component suite. + const trigger = page.getByRole('button', { name: '1 background task running' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('background-task-list-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no task id: ${reported}`) + taskId = TaskId(matched[0]) + + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first() + await row.waitFor({ timeout: 10_000 }) + await expect.poll(() => row.textContent()).toContain(COMMAND) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('flips the open list to the cancelled outcome when the registry settles it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled')) + expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested') + + // The trigger drops its live count once the task leaves running/stopping, + // which is also the proof that settlement reached the browser unprompted. + const idle = page.getByRole('button', { name: '1 background task' }) + await idle.waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) + }) +}) diff --git a/apps/web/tests/snapshots/background-task-list/running.expected.md b/apps/web/tests/snapshots/background-task-list/running.expected.md new file mode 100644 index 0000000000..1adaa965e2 --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/running.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: bash sleep 45 running {{duration}} diff --git a/apps/web/tests/snapshots/background-task-list/settled.expected.md b/apps/web/tests/snapshots/background-task-list/settled.expected.md new file mode 100644 index 0000000000..c97ff5026c --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/settled.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: "bash sleep 45 signal: SIGTERM {{duration}}" diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 40b9be39ca..ee2a1a1a62 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -57,9 +57,9 @@ export function probeFreePort(): Promise { /** * Drive the hero's workspace picker through the composed directory dialog * until the live composer unlocks. A fresh world has no Workspace, so the boot - * lands in the locked view state (startup auto-selection has nothing to + * lands in the Workspace-trigger view state (startup auto-selection has nothing to * select); every scenario that types into the composer must connect one - * first. With nothing to list, the chip gesture raises the dialog directly — + * first. With nothing to list, activating the textarea raises the dialog directly — * adding a workspace is the picker's only entry. The directory is staged here * and adopted through the path editor, which is idempotent across the repeated * connects a scenario may make; creating a folder from inside the dialog (the @@ -73,7 +73,7 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: 'Choose workspace' }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -97,7 +97,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区' }).click() + await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 8147982475..f96a1f8dd1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -74,6 +74,7 @@ "tests/subagent-interrupt.e2e.ts", "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", + "tests/background-task-list.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 6e7d6d6214..6188fec1cf 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2752,6 +2752,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-task` ([`packages/client/ui-task/src/index.ts`](../packages/client/ui-task/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index e3425e0ab7..1aefcaff17 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2753,6 +2753,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash`([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-task`([`packages/client/ui-task/src/index.ts`](../packages/client/ui-task/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool`([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory`([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 2ac502e501..cbb4aee438 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.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/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 448034b54107d5620673052ad388feecc22fe1e1 -01-first-plugin.zh.md: a1838be61f8099831d59afa266fe40ce6bd165bf +01-first-plugin.md: 79df2f42df1f34a7ef32cc81607e1b926a1854ae +01-first-plugin.zh.md: 87cd7a96843d69d68fb95662e59bbfb9ebfc2a37 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 448034b541..79df2f42df 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index a1838be61f..87cd7a9684 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index f97516c457..26ffcac268 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.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/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: 3e88c6f1e1fb1bc825fb74434520993c855010c3 -02-lifecycle-and-effects.zh.md: 3cceecfb8334f2ed2ec9942fa876a0e51e1b315f +02-lifecycle-and-effects.md: 8e75708eb0cba1aceb7fa3dae5ae334995eb113f +02-lifecycle-and-effects.zh.md: bc4489e702ab58b5d1efd8a72fc693ee48af2624 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 3e88c6f1e1..8e75708eb0 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 3cceecfb83..bc4489e702 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index 0d5f9dfccb..372e028a47 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.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/cordis-tutorial/03-services.md -03-services.md: 3f2273ba4061912640e409d7b4deb4cd1b45684f -03-services.zh.md: 657271aba6c0b2e824c79fa822e29c8a6dcf4275 +03-services.md: 32007284be99ef46b4621089c9b3a80317e77189 +03-services.zh.md: d82be29aa69686b8dc10cc6a45a658683c017cbd diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 3f2273ba40..32007284be 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index 657271aba6..d82be29aa6 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index 0f86242ecd..4ce3fb9a65 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.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/cordis-tutorial/04-events.md -04-events.md: 0302adf7c81b802b406f5f6737539ccf3eb970f9 -04-events.zh.md: ae41e757c06a46ef70c250c94a124546769e0112 +04-events.md: db911b9a06d7304d73030a2020de3d465fde2f9c +04-events.zh.md: e0357b2c04785d52a54f839d1b8ff3ecccb027ed diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index 0302adf7c8..db911b9a06 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index ae41e757c0..e0357b2c04 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 08493c67af..4e953918dd 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.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/cordis-tutorial/05-config.md -05-config.md: ad73a732242e4015b2976e6fb193ff464e148dfa -05-config.zh.md: edb3c4113288dfa80e899fb2e5eb21a67d929ca6 +05-config.md: 2357f663135d6fc78a65f9d0952e0bc3f5eefae4 +05-config.zh.md: fbd94d179494ad0b6f73baff2ca525c786cc9e33 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index ad73a73224..2357f66313 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -81,4 +81,4 @@ The loader used in this repo supports a `!!js` tag for config values that must b Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index edb3c41132..fbd94d1794 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -81,4 +81,4 @@ ValidationError: invalid config: 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index a75d53e7dd..6a79850803 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.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/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: 490e3de3a98dd823190deefd47e1b6f2f8ba71b5 -06-composition-and-hmr.zh.md: 4aefb5ecd036929590ab373a2883d90779997b4b +06-composition-and-hmr.md: 87ea26014657ae8c8199e1ebb486556c827d96ca +06-composition-and-hmr.zh.md: 830f55de7c1be351fe701cb068197543602619a7 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index 490e3de3a9..87ea260146 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 4aefb5ecd0..830f55de7c 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -110,4 +110,4 @@ needs-timer is PENDING — a required service is missing 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index cc42357525..fbc6ef1cde 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.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/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 41a30f032ac02d8e9e3b17ac8d9cd13e9973e36a -07-into-the-harness.zh.md: 159cede00d453796f6a2cacae184229d71e17f87 +07-into-the-harness.md: 2d3c23f9f7f7fc6bd6cabd4e7e68ebfc46e20665 +07-into-the-harness.zh.md: 45dc0ee6f07ab3b0275499cd4ab0c436eebdddef diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 41a30f032a..2d3c23f9f7 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -104,4 +104,4 @@ Where to go next: - The generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) — everything you can inject and listen to, each on its owning page. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index 159cede00d..45dc0ee6f0 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - [子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块:可以注入和监听的所有内容,各在其所属页面上。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 234d911a10..06857ab177 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.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/cordis-tutorial/index.md -index.md: cf61fec07acd2022591cd858ba4146b6a4ae1a3d -index.zh.md: 775bf2fce2138b9edd91f5ee46b93b8c4eb4559e +index.md: fb700344e6d07d3864655009d2edac15ee9eede8 +index.zh.md: a68e931d81e745164d8f9a5dc7ec9aec4cd0e590 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index cf61fec07a..fb700344e6 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ The examples use three TypeScript features beyond ordinary modern JavaScript: Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index 775bf2fce2..a68e931d81 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -55,4 +55,4 @@ node --import tsx ../../vendor/cordis/bin.js 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/module-graph.md b/docs/module-graph.md index 7eac17bea2..859a449f63 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -167,6 +167,7 @@ flowchart TD pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] + pkg_client_ui_task["client-ui-task"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] @@ -1170,6 +1171,12 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_task --> pkg_client_locale + pkg_client_ui_task --> pkg_client_runtime + pkg_client_ui_task --> pkg_client_ui_conversation + pkg_client_ui_task --> pkg_client_ui_primitives + pkg_client_ui_task --> pkg_client_ui_slots + pkg_client_ui_task --> pkg_invariants pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1456,6 +1463,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e6a8758f50..cedf087810 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -169,6 +169,7 @@ flowchart TD pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] + pkg_client_ui_task["client-ui-task"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] @@ -1172,6 +1173,12 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_task --> pkg_client_locale + pkg_client_ui_task --> pkg_client_runtime + pkg_client_ui_task --> pkg_client_ui_conversation + pkg_client_ui_task --> pkg_client_ui_primitives + pkg_client_ui_task --> pkg_client_ui_slots + pkg_client_ui_task --> pkg_invariants pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1458,6 +1465,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 052ec4fedd..e1afba5d64 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 -llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 +llm-streaming.md: 17f984166906914c49b2c330bdf57b3cecdbd013 +llm-streaming.zh.md: 6519710dad8a174418bcc97f1bc4b36296ab969e diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 8ae4e8b376..17f9841669 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -234,7 +234,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 7d244ab882..6519710dad 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -238,7 +238,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 6a95ccf73e..75b47a572b 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.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/tasks.md -tasks.md: 2da8212b3edf7111180b60162108af0aebb249c1 -tasks.zh.md: 825a69029ae931f9668cca863c6ebb2324634a9a +tasks.md: 6d205d9a7840aef1c115c97836886f51bed829b9 +tasks.zh.md: 59b7c03d240c45e633f55583b3e08776ec470d52 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 2da8212b3e..6d205d9a78 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -246,6 +246,30 @@ abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSigna */ abstract onTaskDone(listener: TaskDoneListener): () => void +/** +/** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an @@ -258,5 +282,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 825a69029a..59b7c03d24 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 @@ -246,6 +246,30 @@ abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSigna */ abstract onTaskDone(listener: TaskDoneListener): () => void +/** +/** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an @@ -258,5 +282,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml index 04cfa163e7..eaf2543f90 100644 --- a/docs/user/guide/python-sdk.i18n.yaml +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md -python-sdk.md: c48bc95c9334cfd16a925d12726c20b2f968c753 -python-sdk.zh.md: dc31c391a180a742c7dc10807f6ed2ef8d11927d +python-sdk.md: 0713de6f96c110624d5f5a8f2ccf56dd4ce01096 +python-sdk.zh.md: f8fa877b407a43c643fda405e1059fb487182761 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md index c48bc95c93..0713de6f96 100644 --- a/docs/user/guide/python-sdk.md +++ b/docs/user/guide/python-sdk.md @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md index dc31c391a1..f8fa877b40 100644 --- a/docs/user/guide/python-sdk.zh.md +++ b/docs/user/guide/python-sdk.zh.md @@ -30,7 +30,7 @@ python -m pip install deepseek-harness-sdk 从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git deepseek-harness +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness cd deepseek-harness python -m pip install uv==0.11.23 corepack enable diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 68d0156d3a..0cca002d4f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.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/user/guide/quickstart.md -quickstart.md: 635c0dffd6119f95c1951a441691f87f04396b4a -quickstart.zh.md: d0921382bcb0844fa0260c51546622c582867e68 +quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1 +quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 635c0dffd6..e93e5a430f 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,7 +19,7 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index d0921382bc..69cde830bb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,7 +19,7 @@ pnpm -v ## 第一步:安装并配置 API 密钥 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index d1de74a676..9eaba5254b 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/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 examples/mcp-memory/README.md -README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 -README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd +README.md: 7e7de76f4123481b78898b8d62228e4821f3ebc9 +README.zh.md: 3473af862011725844ede95dc9460dd91534b484 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 58da672030..7e7de76f41 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--patch` keeps all three disabled. -Without a repository checkout, download the selected overlay directly: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. - To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 0a2f109f94..3473af8620 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" 请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--patch` 就会让这三项全部保持关闭。 -如果本地没有仓库 checkout,可直接下载所选 overlay: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 - 如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 ## 提供方设置 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 1b26158b47..cd6d752166 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -207,6 +207,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + # Background tasks: the session-header list over the tasksBySession mirror. + - id: ui-task + name: '@deepseek-ai/dsh-client-ui-task' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index f5e5ffe4e5..95e7428f0a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -69,6 +69,7 @@ "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-task": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..c1f21425d6 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/README.md -README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67 +README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..bbc32fb394 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | +| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. | | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..5bfbd1ce6b 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 | +| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a92db49899..980a2f02a8 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -17,6 +17,7 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 99f49ff13a..1a89d0649c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -22,6 +22,7 @@ export type { ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 6addc8c69c..e04fa9c311 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 9743f813da..3d3c29e757 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/runtime/README.md -README.md: d84cd793c34242759ad04edf0debb91558ec3dfc -README.zh.md: e7a74c454f24fcec5e797427c21222b1dc258b44 +README.md: 7c835deb58db149710495f97a2553c3de58d99da +README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index d84cd793c3..7c835deb58 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -25,6 +25,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. +`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e7a74c454f..edf4473bec 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -25,6 +25,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 +`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index 49f5491764..d27cc97eda 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -53,7 +53,7 @@ export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index fd601090bf..741b3d36b2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -4,7 +4,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, - SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId, + SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -48,6 +48,8 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RpcError | null subagentsByParent: Readonly> + /** Background tasks per session; an absent key is an empty set. */ + tasksBySession: Readonly> currentAddress: SubagentAddress | undefined } @@ -138,6 +140,11 @@ export class SessionManager { private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() + /** + * Background tasks per session, last-wins from `session/tasks`. An empty set + * is stored as an absent key, so absence and `[]` are one representation. + */ + private readonly tasksBySession = new Map() private selected: SessionId | undefined @@ -682,10 +689,23 @@ export class SessionManager { this.notifier.markDirty() return } + if (frame.type === 'session/tasks') { + // Whole-set snapshot, so last-wins with no reconciliation. The Host omits + // the baseline for an empty set, which is the same fact an emptying change + // reports as `[]` — both land as an absent key. + if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId) + else this.tasksBySession.set(frame.sessionId, frame.tasks) + this.notifier.markDirty() + return + } if (frame.type === 'session/subscribed') { // Rows past the host's durable baseline rode state a restart lost; drop // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) + // Same re-baseline reasoning as the queue below: this generation sends a + // task baseline only when the set is non-empty, so a mirror kept from the + // previous generation would survive as a phantom list. + this.tasksBySession.delete(frame.sessionId) this.notifier.markDirty() // New mux-generation baseline: discard the previous queue snapshot. // The host omits session/queue when the live queue is empty, so retaining @@ -804,6 +824,11 @@ export class SessionManager { } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone + // Owner disposal already dropped these registry-side, but that lands on + // the mux stream while this frame rides the host stream, so the two have + // no relative order. Clearing here makes a detached Activation's rows + // disappear whichever arrives first. + this.tasksBySession.delete(frame.sessionId) if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -1040,6 +1065,7 @@ export class SessionManager { phase: this.listPhase, error: this.listError, subagentsByParent: Object.fromEntries(this.catalogs), + tasksBySession: Object.fromEntries(this.tasksBySession), currentAddress: current === undefined ? undefined : this.addresses.get(current), } } diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 0f0f68827d..2b7267402e 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -16,7 +16,7 @@ */ import type { Context, Fiber } from '@deepseek-ai/cordis' import type { - IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId, + IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -86,6 +86,12 @@ export interface SessionListState { phase: SessionListPhase /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> + /** + * Background tasks each session can see, mirrored last-wins from + * `session/tasks`. A missing key is an empty set — the Host sends no baseline + * for a session without tasks — so consumers read absence, never a sentinel. + */ + tasksBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ currentAddress: SubagentAddress | undefined } @@ -291,7 +297,7 @@ export class SessionsService implements ISessions { ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -649,7 +655,7 @@ export class SessionsService implements ISessions { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { const { - items, current, phase, subagentsByParent, currentAddress, + items, current, phase, subagentsByParent, tasksBySession, currentAddress, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -719,7 +725,7 @@ export class SessionsService implements ISessions { ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), }) } - this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) + this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress }) this.pruneScopes() } diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c69465df45..7eba55fd64 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -1110,3 +1110,60 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) }) + +describe('background-task mirror', () => { + const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ + id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, + }) + const tasksFrame = (sessionId: SessionId, tasks: unknown[]) => + ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) + + it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const first = manager.getListSnapshot().tasksBySession + expect(first[S1]).toEqual([view()]) + expect(first[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })])) + expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })]) + }) + + it('stores an emptied set as an absent key so absence and [] read alike', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) + manager.handleMuxEnvelope(tasksFrame(S1, [])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope({ + rpcId: 's' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 }, + }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('drops the rows when the session is removed, whichever stream lands first', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('notifies list subscribers so an open header re-renders without a poll', async () => { + const manager = new SessionManager(new FakeApiClient()) + const seen = vi.fn() + manager.subscribe(seen) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + // The notifier batches on a microtask; the frame itself is already applied. + await Promise.resolve() + expect(seen).toHaveBeenCalled() + }) +}) diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 315efd8f13..fc41c83975 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -202,7 +202,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) this.channel = new SessionProvideChannel({ rebuildBundles: () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4f28cf5627..812cc04c1c 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: 7c4855a75abb982ff55b903808d6a65c42cbc91c -README.zh.md: c3a5d7beb2e90289f4340fc251fd3527370e3e23 +README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8 +README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7c4855a75a..605bba15d7 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compact/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compact/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. @@ -36,7 +36,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c3a5d7beb2..e6a2dd0b54 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compact/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compact/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 @@ -36,7 +36,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 36b517f5c4..602e54d5d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -122,8 +122,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * takeover election hides rather than unmounts it and the textarea DOM * survives). Session-maybe: the bar stays mounted across the * no-session/session transition — the no-workspace hero renders the SAME - * textarea DOM disabled instead of a parallel inert tree — with the - * machine hooks absent until a session is current. InputBar registers + * textarea DOM as a read-only Workspace-picker trigger instead of a + * parallel inert tree — with the machine hooks absent until a session is + * current. InputBar registers * here from this package's apply; its machine state arrives through the * standard provide channel (useInput + inputActions), the keyboard * command face through its own inject. @@ -380,11 +381,14 @@ export interface ComposerBarOwnerProps { */ blocked?: { readonly reason: string } /** - * Inert no-workspace state: the bar renders its normal DOM fully disabled - * (textarea, add, send) so the workspace pick transitions in place instead - * of swapping component trees. + * Inert no-workspace state: the bar locks message actions while preserving + * its normal DOM so the Workspace pick transitions in place. */ disabled?: boolean + /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */ + workspacePickerOpen?: boolean + /** Open the existing Workspace picker from the inert textarea. */ + onRequestWorkspace?: () => void placeholder?: string /** Optional content rendered above the textarea. */ accessory?: ReactNode diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 10f757d15e..810b9ad4f3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -123,7 +123,7 @@ export function ConversationRoot({ ) - // The placeholder chip ("Choose workspace") and the inert input travel + // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel // together: no workspace picked yet (cold start, no session at all), or a // blank session whose workspace vanished (deleted from the sidebar). The // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, @@ -136,7 +136,12 @@ export function ConversationRoot({ const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert - ? { disabled: true, placeholder: t('placeholder.workspace') } + ? { + disabled: true, + placeholder: t('placeholder.workspace'), + workspacePickerOpen: pickerOpen, + onRequestWorkspace: () => { setPickerOpen(true) }, + } : blocked // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 4603013e65..ad1a6ed275 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -102,6 +102,40 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +/* No-workspace trigger state: dashed l4 stroke marks the card as a pick-a- + workspace affordance rather than a live composer; hover answers in the + business blue to invite the click. Native `dashed` has a fixed browser + pattern, so the stroke is an ::after overlay: theme-token background masked + by an SVG dash ring (stroke-width 2 centered on the box edge = 1px visible + inside), which keeps the 22px radius and both themes. */ +.cardWorkspaceTrigger { + border-color: transparent; + cursor: pointer; +} + +.cardWorkspaceTrigger::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: 22px; + background: var(--dsw-alias-border-l4); + transition: background-color 100ms ease; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + pointer-events: none; +} + +/* Disabled toolbar controls neither receive nor swallow clicks in the trigger + state: pointer events fall through to the card's own click handler, making + the full capsule one pick target. */ +.cardWorkspaceTrigger :disabled { + pointer-events: none; +} + +.cardWorkspaceTrigger:hover::after { + background: var(--dsw-alias-state-business-primary); +} + .dragActive { border-color: var(--dsw-alias-state-business-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); @@ -313,6 +347,10 @@ cursor: not-allowed; } +.input[aria-haspopup='menu'] { + cursor: pointer; +} + .mirror { visibility: hidden; pointer-events: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6ebee98c95..5423a2bac6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -39,8 +39,9 @@ export function InputBar({ useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder, - accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, blocked, + workspacePickerOpen = false, onRequestWorkspace, + placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -109,6 +110,12 @@ export function InputBar({ // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + // The no-workspace textarea remains the resident DOM node but acts as the + // existing picker trigger. Message controls stay locked until a Session + // exists; the trigger itself is read-only rather than disabled so pointer + // and keyboard users can reach the recovery action. + const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined + const textareaDisabled = removed || (locked && !workspaceTrigger) const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') @@ -233,8 +240,15 @@ export function InputBar({ }, []) const onKeyDown = (e: KeyboardEvent): void => { - // Absent machine (no session): the textarea is disabled so events cannot - // fire; the guard narrows the faces for the paths below. + if (workspaceTrigger) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onRequestWorkspace() + } + return + } + // Absent machine without a Workspace recovery action stays disabled; the + // guard narrows the faces for the paths below. if (keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. @@ -298,7 +312,7 @@ export function InputBar({ } const onChange = (e: ChangeEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) @@ -324,7 +338,7 @@ export function InputBar({ /* oxlint-enable typescript/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { - if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events + if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return @@ -349,7 +363,7 @@ export function InputBar({ } const onPaste = (e: React.ClipboardEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined) return // absent machine: no draft can accept a paste if (machineBusy || locked) return const files = Array.from(e.clipboardData.items) .filter(item => item.kind === 'file') @@ -539,10 +553,17 @@ export function InputBar({ {notice.text} )} + {/* Trigger clicks land on the card, not the textarea: the toolbar row's + disabled controls swallow clicks otherwise (the CSS state disarms + their pointer events), so the WHOLE capsule is the pick target. + pointerdown stops here so the Menu's outside-close cannot race the + click's reopen (close-then-open flickers the chip's open echo). */} {dropError !== null &&
{dropError}
}
{ e.stopPropagation() } : undefined} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} @@ -590,8 +611,11 @@ export function InputBar({ ref={inputRef} className={css.input} value={draft} - disabled={locked} - readOnly={machineBusy} + disabled={textareaDisabled} + readOnly={machineBusy || workspaceTrigger} + aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined} + aria-haspopup={workspaceTrigger ? 'menu' : undefined} + aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined} data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (parentOffline ? t('placeholder.parentOffline') diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 5df7a62698..9e52d2a0d5 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -82,10 +82,20 @@ describe('resident composer', () => { runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) + runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) const view = runtime.renderRoot() const textarea = view.container.querySelector('textarea') expect(textarea).not.toBeNull() - expect(textarea!.disabled).toBe(true) + expect(textarea!.disabled).toBe(false) + expect(textarea!.readOnly).toBe(true) + expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') + expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') + fireEvent.click(textarea!) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') + expect(textarea!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.keyDown(textarea!, { key: 'Enter' }) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() }) @@ -111,7 +121,8 @@ describe('resident composer', () => { const textarea = view.container.querySelector('textarea')! const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') - expect(textarea.disabled).toBe(true) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) @@ -131,6 +142,7 @@ describe('resident composer', () => { expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe) expect(workspaceProbe.textContent).toBe('true:1') expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(false) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 084e4ffa3a..f1d095ab7e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -134,7 +134,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx index e8d44c9bf3..573e3a97ed 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx @@ -16,7 +16,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 6e0df4dd8c..bcda26f6a8 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -109,7 +109,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, @@ -165,7 +165,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index b9aa56f7c8..f7b0669e3e 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -60,6 +60,9 @@ interface BenchOptions { running?: boolean subagent?: Exclude disabled?: boolean + inert?: boolean + workspacePickerOpen?: boolean + onRequestWorkspace?: () => void promptError?: ConversationSnapshot['promptError'] /** Authoritative queue rows served to the machine overlay (empty = none). */ queue?: ConversationSnapshot['queue'] @@ -136,7 +139,7 @@ function bench(over?: BenchOptions) { useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, @@ -168,6 +171,9 @@ function bench(over?: BenchOptions) { t: over?.t ?? makeTranslate(zh, commonZh), renderSlot, variant: over?.variant ?? 'composer', + ...(over?.inert === true ? { disabled: true } : {}), + ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}), + ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}), ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}), ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}), @@ -785,6 +791,40 @@ describe('running and lock semantics', () => { expect(custom.textarea.placeholder).toBe('Custom placeholder') }) + it('the inert textarea opens the Workspace picker by pointer or keyboard', () => { + const onRequestWorkspace = vi.fn() + const { view, textarea } = bench({ + inert: true, + workspacePickerOpen: false, + onRequestWorkspace, + placeholder: '选择一个工作区开始', + }) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) + expect(textarea.getAttribute('aria-haspopup')).toBe('menu') + expect(textarea.getAttribute('aria-expanded')).toBe('false') + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(textarea) + fireEvent.keyDown(textarea, { key: 'Enter' }) + fireEvent.keyDown(textarea, { key: ' ' }) + expect(onRequestWorkspace).toHaveBeenCalledTimes(3) + + // The WHOLE capsule is the pick target, and its pointerdown never reaches + // the document — the open picker's outside-close must not race the reopen. + const card = view.container.querySelector('[data-composer-card]') as HTMLElement + fireEvent.click(card) + expect(onRequestWorkspace).toHaveBeenCalledTimes(4) + const onDocumentPointerDown = vi.fn() + document.addEventListener('pointerdown', onDocumentPointerDown) + try { + fireEvent.pointerDown(card) + } finally { + document.removeEventListener('pointerdown', onDocumentPointerDown) + } + expect(onDocumentPointerDown).not.toHaveBeenCalled() + }) + it('the plan projection swaps the placeholder while its effective target is plan mode', () => { const active = bench({ plan: { active: true, pending: false } }) expect(active.textarea.placeholder).toBe('描述你的任务以生成计划') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index bb6ba10990..69a5b9d6ba 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -40,7 +40,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 0722ccb2e1..1f9642e236 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -126,7 +126,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 616407a82e..be5cd3be28 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -110,7 +110,7 @@ function mount( ids: listed ? [root, SID] : [root], byId: { [root]: rootRow, ...listed && { [SID]: childRow } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) @@ -296,8 +296,12 @@ describe('ConversationRoot resident composer', () => { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') as HTMLTextAreaElement - expect(box.disabled).toBe(true) + expect(box.disabled).toBe(false) + expect(box.readOnly).toBe(true) + expect(box.getAttribute('aria-haspopup')).toBe('menu') expect(box.placeholder).not.toBe('select a model first') + const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner + expect(modelSeat).toEqual({ locked: true }) }) it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index ae6c0f07ee..ebc140405e 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -61,6 +61,7 @@ function props( }, current: PARENT, phase: 'ready', subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, + tasksBySession: {}, currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml new file mode 100644 index 0000000000..39f800e04b --- /dev/null +++ b/packages/client/ui-task/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/client/ui-task/README.md +README.md: a1430db55c7519c612e5d39de4627c750976d2d5 +README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md new file mode 100644 index 0000000000..a1430db55c --- /dev/null +++ b/packages/client/ui-task/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +English | [中文](README.zh.md) + +Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility. + +The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. + +Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to. + +Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md). + +## Model Experience + +None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running. +- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md new file mode 100644 index 0000000000..5e29c93980 --- /dev/null +++ b/packages/client/ui-task/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +[English](README.md) | 中文 + +Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 + +只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 + +终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。 + +Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。 + +## Model Experience + +无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。 + +#### KV Cache effect + +无;本包从不组装或发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 +- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json new file mode 100644 index 0000000000..c8354f8f6c --- /dev/null +++ b/packages/client/ui-task/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-task", + "description": "Session-header background-task list: live registry state mirrored from session/tasks frames", + "version": "0.0.1-rc.1", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-task" + }, + "publishConfig": { + "access": "restricted" + }, + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css new file mode 100644 index 0000000000..b6ac05aa31 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -0,0 +1,125 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 336px; + max-width: min(400px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +.status, +.duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; +} + +/* A failed task's detail is the producer's raw error text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.duration { + font-variant-numeric: tabular-nums; +} diff --git a/packages/client/ui-task/src/client/TaskListAction.tsx b/packages/client/ui-task/src/client/TaskListAction.tsx new file mode 100644 index 0000000000..ede2eca377 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './TaskListAction.module.css' + +/** Full props for the session-header background-task action. */ +export type TaskListActionProps = + PropsRuntime<'conversation.session.header.actions'> & PropsLocale + +/** Stable empty list so a session with no tasks keeps one array identity. */ +const NO_TASKS: readonly TaskView[] = [] + +/** A task the registry still holds open, and whose duration therefore ticks. */ +function isLive(task: TaskView): boolean { + return task.status === 'running' || task.status === 'stopping' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled task status: ${JSON.stringify(value)}`) +} + +/** + * Status marker semantics. `stopping` and `killed` share the attention color: + * both mean the work ended (or is ending) on request rather than on its own. + */ +function dotState(status: TaskView['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'stopping': return 'warning' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Human status word for the row and its accessible name. */ +function statusLabel(status: TaskView['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'stopping': return t('status.stopping') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** + * Elapsed time in at most two adjacent units. A background task that outlives + * an hour is already exceptional, so hours is the widest unit — beyond that the + * figure stays in hours rather than growing a day/month vocabulary no producer + * currently reaches. + */ +function formatDuration(elapsedMs: number, t: TranslateNS): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('duration.hours', { hours, minutes }) + if (minutes > 0) return t('duration.minutes', { minutes, seconds }) + return t('duration.seconds', { seconds }) +} + +/** + * Live rows first in start order, then settled rows newest-first. Two tasks + * that settled in the same millisecond fall back to start order, so the sort + * never depends on the host's map iteration. + */ +function ordered(tasks: readonly TaskView[]): TaskView[] { + return [...tasks].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} + +/** + * Session-header entry point for this session's background tasks. It renders + * nothing at all until the session has at least one task, so an ordinary + * conversation never grows a control for a capability it is not using. + * @param props - runtime slot currency plus the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) { + const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS + const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered(tasks), [tasks]) + const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks]) + + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent): void => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + return () => { document.removeEventListener('pointerdown', closeOutside) } + }, [open]) + + // The clock only runs while an open list is showing something that moves. + useEffect(() => { + if (!open || liveCount === 0) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, liveCount]) + + // The last task disappearing removes this control; close first so focus does + // not vanish from an unmounting node. + useEffect(() => { + if (tasks.length === 0 && open) setOpen(false) + }, [tasks.length, open]) + + if (tasks.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length }) + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
+ + {open + ? ( +
    + {rows.map((task) => { + const live = isLive(task) + const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt + const duration = formatDuration(elapsed, t) + const status = statusLabel(task.status, t) + return ( +
  • + + {task.kind} + {task.label} + {task.detail ?? status} + + {duration} + +
  • + ) + })} +
+ ) + : null} +
+ ) +} diff --git a/packages/client/ui-task/src/client/index.ts b/packages/client/ui-task/src/client/index.ts new file mode 100644 index 0000000000..792b5f39d4 --- /dev/null +++ b/packages/client/ui-task/src/client/index.ts @@ -0,0 +1,40 @@ +/** + * Background-task plugin, browser half: contributes one session-header action + * that renders this session's `ctx.tasks` records. The data arrives entirely + * through the `tasksBySession` list mirror, so the plugin issues no RPC and + * holds no state of its own beyond popover visibility. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction } from './TaskListAction.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type TaskKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-task list copy. */ + 'task': TaskKey + } +} + +export type { TaskListActionProps } from './TaskListAction.tsx' + +/** Required services for locale registration and header-slot contribution. */ +export const inject = ['sessions', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'task-list', + // After the subagent catalog: session lineage reads before process work. + order: 20, + locale: NS, + }, TaskListAction), + ) +} diff --git a/packages/client/ui-task/src/client/locales.ts b/packages/client/ui-task/src/client/locales.ts new file mode 100644 index 0000000000..8c03da56eb --- /dev/null +++ b/packages/client/ui-task/src/client/locales.ts @@ -0,0 +1,45 @@ +/** `task` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'task' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', + 'status.running': '运行中', + 'status.stopping': '正在停止', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分', + 'duration.title.live': '已运行 {duration}', + 'duration.title.done': '耗时 {duration}', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} background task running', + 'count.live.other': '{count} background tasks running', + 'count.idle.one': '{count} background task', + 'count.idle.other': '{count} background tasks', + 'list.aria': 'Background tasks', + 'status.running': 'running', + 'status.stopping': 'stopping', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Running for {duration}', + 'duration.title.done': 'Took {duration}', +} + +/** Key domain of the `task` namespace (zh is the source of truth). */ +export type TaskKey = keyof typeof zh diff --git a/packages/client/ui-task/src/css-modules.d.ts b/packages/client/ui-task/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-task/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-task/src/index.ts b/packages/client/ui-task/src/index.ts new file mode 100644 index 0000000000..3962e1b113 --- /dev/null +++ b/packages/client/ui-task/src/index.ts @@ -0,0 +1,9 @@ +/** + * Background-task list plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this source plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-task/src/invariant.ts b/packages/client/ui-task/src/invariant.ts new file mode 100644 index 0000000000..cfdad3c1f2 --- /dev/null +++ b/packages/client/ui-task/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`. + * @module @deepseek-ai/dsh-client-ui-task/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-task-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this package is a read-only projection of the + * `tasksBySession` mirror onto one header slot entry. It emits no cordis + * events, owns no cross-plugin mutable state, and its single slot registration + * proves disposal through the HMR-safety spec. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..142668387f --- /dev/null +++ b/packages/client/ui-task/tests/browser-plugin.spec.ts @@ -0,0 +1,91 @@ +/** + * ui-task plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotsService (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as TaskInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('sessions', {}) + // The locale plugin binds a settings scope, which reads the connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-task browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['sessions', 'slots', 'locale']) + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('task-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('task-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-task node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-task invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(TaskInvariant) + await fiber.await() + expect(TaskInvariant.name).toBe('client-ui-task-invariant') + expect(TaskInvariant.inject).toEqual(['invariants']) + // Emitting an unrelated event proves the companion installed no audit. + expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow() + await fiber.dispose() + }) +}) diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.spec.tsx new file mode 100644 index 0000000000..0d03af3ec0 --- /dev/null +++ b/packages/client/ui-task/tests/task-list-action.spec.tsx @@ -0,0 +1,239 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx' +import { zh } from '../src/client/locales.ts' + +// Live rows render `now - startedAt`, so every assertion needs a pinned clock. +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const START = 1_700_000_000_000 +const t: TaskListActionProps['t'] = makeTranslate(zh) + +function task(over: Partial = {}): TaskView { + return { + id: 'bash-1' as TaskView['id'], + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: START, + ...over, + } +} + +function props(tasks: readonly TaskView[] | undefined): TaskListActionProps { + const state = { + ids: [SESSION], + byId: {}, + current: SESSION, + phase: 'ready', + subagentsByParent: {}, + tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks }, + currentAddress: undefined, + } satisfies SessionListState + function useSessions(select: (snapshot: SessionListState) => T): T { + return select(state) + } + return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps +} + +/** + * Rows in render order as `[kind, label, status, duration]`. Adjacent spans + * carry no whitespace between them, so the cells are read one element at a + * time rather than split out of a flattened string. + */ +function rowCells(): string[][] { + return within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(row => [...row.children] + .map(cell => cell.textContent ?? '') + .filter(text => text !== '')) +} + +describe('TaskListAction visibility', () => { + it('renders nothing while the session has no tasks', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts only live tasks, and falls back to the total when none are live', () => { + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() + + rerender() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() + }) + + it('closes and unmounts when the last task disappears while the list is open', () => { + const { container, rerender } = render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + + rerender() + expect(container.innerHTML).toBe('') + }) +}) + +describe('TaskListAction rows', () => { + it('orders live tasks by start, then settled tasks newest-first', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()).toEqual([ + ['bash', 'earlier live', '运行中', '0秒'], + ['bash', 'later live', '运行中', '0秒'], + ['bash', 'new done', '已失败', '9秒'], + ['bash', 'old done', '已完成', '1秒'], + ]) + }) + + it('breaks a settled tie on start order so map iteration never decides it', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) + }) + + it('prefers the producer detail over the generic status word', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('signal: SIGTERM') + }) + + it('renders every status word, including the stopping transition', () => { + render() + fireEvent.click(screen.getByRole('button')) + const words = rowCells().map(cells => cells[2]) + expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) + }) +}) + +describe('TaskListAction duration', () => { + it('advances a live row once per second and freezes a settled one', () => { + vi.setSystemTime(START + 1_000) + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('1秒') + expect(rowCells()[1]).toContain('4秒') + + act(() => { vi.advanceTimersByTime(2_000) }) + expect(rowCells()[0]).toContain('3秒') + expect(rowCells()[1]).toContain('4秒') + }) + + it('widens to minutes and then hours, and never shows a negative figure', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) + }) + + it('runs no clock while the list is closed', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + expect(interval).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button')) + expect(interval).toHaveBeenCalledTimes(1) + }) + + it('runs no clock for an open list holding only settled tasks', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + fireEvent.click(screen.getByRole('button')) + expect(interval).not.toHaveBeenCalled() + }) +}) + +describe('TaskListAction dismissal', () => { + it('closes on Escape and returns focus to the trigger', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('ignores other keys and a closed-list Escape', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + }) + + it('closes on an outside pointer press but not on one inside', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + + fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('TaskListAction wire tolerance', () => { + it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => { + // `finishedAt` is optional on the wire; the Host always sets it, so this + // covers a producer or carrier that ever stops doing so. + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ + ['finished', '3秒'], + ['no finish', '0秒'], + ]) + }) + + it('falls back to start order when neither settled task carries a finish time', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) + }) +}) diff --git a/packages/client/ui-task/tsconfig.json b/packages/client/ui-task/tsconfig.json new file mode 100644 index 0000000000..76ff7ad167 --- /dev/null +++ b/packages/client/ui-task/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-task/tsdown.config.ts b/packages/client/ui-task/tsdown.config.ts new file mode 100644 index 0000000000..2be71f65dc --- /dev/null +++ b/packages/client/ui-task/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index 7b1e11a69b..a6aa481879 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -22,7 +22,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 8840e6a19d..5939655118 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -112,7 +112,7 @@ async function bench(snapshot: ConversationSnapshot) { ids: [SID], byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index ab1a0a9632..8010fb8763 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -30,7 +30,7 @@ function listStore() { }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) } diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 3990cba157..1726c81136 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -159,7 +159,7 @@ describe('FileMutationRow diff card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -311,13 +311,13 @@ describe('DetailsPanel diff Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 14baac6b9b..ae719173d5 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -173,7 +173,7 @@ describe('ReadRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -257,13 +257,13 @@ describe('DetailsPanel Output section (read)', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 67d5eea6ca..3ff068b3c9 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -380,7 +380,7 @@ describe('DetailsPanel Output section (search)', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c0af5adff7..dd39b8bc88 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -348,7 +348,7 @@ describe('BashRow terminal card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -448,13 +448,13 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ @@ -649,7 +649,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 67ec839471..04535a0954 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -210,7 +210,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index e4ea44d925..5235235d90 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -140,7 +140,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) { /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 647e29f6d3..5a6d145e36 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), byId: Object.fromEntries(items.map(item => [item.id, item])), current: undefined, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ workspaceId: wid(id), path: `/projects/${id}`, title, diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 6c46275894..918a3f6c0e 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index e7788a3f63..132b64f4b2 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -28,7 +28,7 @@ function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8bf63adb20..d89a5f518b 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: d59f3f5ddb9929356e23a467ce5840f1673663f7 -README.zh.md: 737361f2bd85c0ea02b9d29734f58c34bc324969 +README.md: 2a27ab9f2cfcd4f12c32b815583b13f439b3eaac +README.zh.md: ba49ccff82bab1e677bad1495e48b8503564ae1d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index d59f3f5ddb..2a27ab9f2c 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,6 +36,8 @@ Session model selection is a session-domain contract. `session.models` returns t Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. +Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. + Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 737361f2bd..ba49ccff82 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,6 +36,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 +后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 + Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 6ccec04164..7f6d6e53aa 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -65,6 +65,7 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index ea41122cc9..6b71a6ce87 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -39,7 +39,7 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, + QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' import { @@ -49,6 +49,9 @@ import { } from './api/session-search.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. import type {} from '@deepseek-ai/dsh-session-projection' +// Type-only: resolves `ctx.get('tasks')` to the background task registry. +import type {} from '@deepseek-ai/dsh-tasks' +import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. @@ -406,6 +409,22 @@ function subscribeSession(queue: FrameQueue>, session: Sess queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) } +/** + * Project registry snapshots onto the wire view, dropping the three internal + * fields {@link TaskView} documents as absent. + */ +function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] { + return snapshots.map(task => ({ + id: task.id, + kind: task.kind, + label: task.label, + status: task.status, + ...task.detail === undefined ? {} : { detail: task.detail }, + startedAt: task.startedAt, + ...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt }, + })) +} + /** * Whether the session's conversation has started: no turn has run yet (a * turn is one model-loop execution). Standalone plugin events — command @@ -3212,6 +3231,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) } } + // Background-task baseline. `ctx.agents.get` is the non-resuming read: + // a session with no live Agent owns no tasks, so it correctly sees only + // the unowned ones, and listing never revives a cold session. An empty + // set sends nothing — absence is how the client reads "no tasks". + const tasks = ctx.get('tasks') + if (tasks !== undefined) { + for (const session of ctx.sessions.list()) { + const views = taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } + } + } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream // opened mid-turn) backscans the session's in-memory events instead. @@ -3239,10 +3271,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }), ctx.on('session/created', (session: Session) => { subscribeSession(queue, session) + // The subscribe frame clears the client's task mirror, and a + // session born after the stream opened missed the baseline loop. + // Unowned tasks are visible to it from birth, so without this it + // would show none until the next registry change. + const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) }), + ...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => { + if (owner !== undefined) { + // The exact owner instance the fence compares against, so the + // push stays correct even while that Agent's scope is tearing + // down and a lookup by id would already miss. + queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) })) + return + } + // An unowned task is visible to every caller, so every subscribed + // session's set changed with it. + for (const session of ctx.sessions.list()) { + queue.push(frame({ + type: 'session/tasks', + sessionId: session.id, + tasks: taskViews(tasks.list(ctx.agents.get(session.id))), + })) + } + })], ] return queue.iterate(signal, () => { muxQueues.delete(queue) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index c06efb9057..02516c13bb 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, } from './sessions.schema.ts' +import { taskViewSchema } from './tasks.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question fields validated strictly against core dsh-user-interaction. */ @@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ message: messageSchema, })), }), + z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 351ea4115e..73bb9d8bc2 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' +import type { TaskView } from './tasks.ts' import type { WorkspaceView } from './workspace.ts' // Client-side consumers take the render-intent vocabulary from the contract; @@ -81,6 +82,20 @@ export type MuxFrame = * in QueueDock, while pending steering renders at the conversation tail. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } + /** + * Complete set of background tasks this session can see, after every registry + * commit that changes it: registration, the stopping transition, settlement, + * and owner-disposal removal. The registry is process-local and holds no + * durable event, so — exactly like `session/queue` — the whole snapshot is + * what makes a start, a kill, a reconnect, and a second tab converge on one + * authoritative value. + * + * Sent as a subscription baseline only for a session that currently has + * tasks; an absent key means an empty set. A change that empties the set + * still sends `[]`, since that transition is the only one absence cannot + * express. + */ + | { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index e8eb3f3272..f49dfc9699 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -48,6 +48,7 @@ export type { SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' +export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' diff --git a/packages/host/apiproxy/src/api/tasks.schema.ts b/packages/host/apiproxy/src/api/tasks.schema.ts new file mode 100644 index 0000000000..263b895b45 --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.schema.ts @@ -0,0 +1,33 @@ +/** + * tasks domain zod schemas: the branded task id and the wire view carried by + * `session/tasks` frames. + */ + +import { z } from 'zod' +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' +import type { TaskView } from './tasks.ts' +import type { Wire } from './rpc.schema.ts' + +/** TaskId: one brand cast after non-empty string validation. */ +export const taskIdSchema = z.string().min(1) as unknown as z.ZodType + +/** + * One wire task view. `kind` stays an open string because producer plugins + * extend the registry's kind map by declaration merging, so the closed set is + * not knowable at this boundary. + */ +export const taskViewSchema = z.object({ + id: taskIdSchema, + kind: z.string().min(1), + label: z.string().min(1), + status: z.union([ + z.literal('running'), + z.literal('stopping'), + z.literal('completed'), + z.literal('killed'), + z.literal('failed'), + ]), + detail: z.string().optional(), + startedAt: z.number().int().nonnegative(), + finishedAt: z.number().int().nonnegative().optional(), +}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/tasks.ts b/packages/host/apiproxy/src/api/tasks.ts new file mode 100644 index 0000000000..f327b7cc1d --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.ts @@ -0,0 +1,36 @@ +/** + * Browser-safe background-task domain contract. The registry's live records + * never cross the wire; a view is the subset a human list needs, minted fresh + * per push. + */ + +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +/** + * One background task as the client sees it. + * + * Three registry fields are deliberately absent. `ownerSession` is redundant + * beside the frame's own `sessionId`; `reported` is an internal notice-delivery + * bit with no user meaning; `outputLimitBytes` is producer-owned model + * presentation policy that never reaches a human surface. + */ +export interface TaskView { + /** Registry-issued `-N` identity, stable for the task's whole life. */ + id: TaskId + /** + * Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare + * string because producer plugins extend the kind map by declaration merging, + * so no client build can enumerate the closed set. + */ + kind: string + /** Producer-supplied one-line label: the command, or the delegation description. */ + label: string + /** Current lifecycle state. */ + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + /** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while live. */ + finishedAt?: number +} diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts new file mode 100644 index 0000000000..0bb7aa0333 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -0,0 +1,263 @@ +/** + * Background-task carrier paths of the host ApiProxy: the subscription + * baseline is sent only for a session that has tasks, every registry change + * pushes that owner's whole set, an unowned change fans out to every + * subscribed session, the projection drops the three internal snapshot + * fields, a composition without `ctx.tasks` emits nothing, and listing never + * resumes a cold session. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +type TaskFrame = Extract + +/** + * A producer whose settlement the test drives. `cancel` deliberately does not + * settle, so a kill is observable as the distinct `stopping` step before the + * test supplies the terminal outcome and its detail. + */ +function producer(label = 'sleep 60') { + let settle!: (outcome: TaskOutcome) => void + // A stream producer, so the carrier CAN consume the cursor if it ever calls + // `read()`; `reads` is what proves it never does. + const reads = { count: 0 } + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, + }), + } + return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } } +} + +async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + if (withRegistry) { + await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('api-proxy-test') + } + const session = ctx.sessions.create() + const agent = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + return { ctx, session, agent } +} + +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + +/** Drain the mux until `count` session/tasks frames arrived, then abort. */ +async function collect( + iterable: AsyncIterable>, + count: number, + abort: AbortController, +): Promise { + const frames: MuxFrame[] = [] + for await (const envelope of iterable) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort() + } + return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks') +} + +describe('session/tasks subscription baseline', () => { + it('is omitted for a session with no tasks — absence is the empty set', async () => { + const { ctx, session } = await harness(true) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort() + } + })() + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true) + void session + }) + + it('carries the live set for a session that already has tasks when the stream opens', async () => { + const { ctx, session, agent } = await harness(true) + ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent }) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + expect(baseline?.sessionId).toBe(session.id) + expect(baseline?.tasks).toHaveLength(1) + const [task] = baseline?.tasks ?? [] + expect(task?.startedAt).toBeTypeOf('number') + expect({ ...task, startedAt: 0 }).toEqual({ + id: 'bash-1', + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 0, + }) + }) +}) + +describe('session/tasks change pushes', () => { + it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => { + const { ctx, session, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + + const frames = await collected + expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) + expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed']) + // Terminal detail rides the same whole-set push; no separate signal. + expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM') + expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number') + }) + + it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => { + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) + + const [frame] = await collected + const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {}) + expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status']) + }) + + it('fans an unowned change out to every subscribed session', async () => { + const { ctx } = await harness(true) + const second = ctx.sessions.create() + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal) + const collected = collect(stream, 2, abort) + + ctx.tasks.start(producer('open to every caller').spec) + + const frames = await collected + expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) + expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) + for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller') + }) + + it('serves a cold session the unowned set without resuming it', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-tasks') + let loaded = false + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load: () => { loaded = true; throw new Error('task listing must not load a cold log') }, + } as never) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + + ctx.tasks.start(producer().spec) + await collected + expect(loaded).toBe(false) + expect(ctx.agents.get(coldId)).toBeUndefined() + }) +}) + +describe('session/tasks without the registry', () => { + it('emits no frames at all, so the client renders no entry point', async () => { + const { ctx, session } = await harness(false) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort() + } + })() + session.append('turn/start', { turn: 1 }) + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + }) +}) + +describe('session/tasks never consumes model output', () => { + it('drives the whole lifecycle without calling the single consuming cursor', async () => { + // `ctx.tasks.read()` consumes the one output cursor, so a carrier read + // silently takes bytes the model's `task_output` will never see. The + // failure is invisible at the call site, which is why this asserts the + // count rather than trusting review. + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(p.reads.count).toBe(0) + }) + + it('reads nothing while minting the subscription baseline either', async () => { + const { ctx, agent } = await harness(true) + const p = producer() + ctx.tasks.start({ ...p.spec, owner: agent }) + + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + + expect(baseline?.tasks).toHaveLength(1) + expect(p.reads.count).toBe(0) + }) +}) + +describe('session/tasks baseline for a session born after the stream opened', () => { + it('carries the already-visible unowned set to the new session', async () => { + const { ctx } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal) + + // One unowned task exists before the new session is created; the subscribe + // frame clears the client mirror, so the baseline has to follow it. + ctx.tasks.start(producer('visible to every caller').spec) + const created = ctx.sessions.create() + + const frames = await collect(stream, 2, abort) + const forNew = frames.filter(frame => frame.sessionId === created.id) + expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller') + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 3be4bf5715..789639b70d 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -436,6 +436,11 @@ describe('events frame schemas', () => { }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, + { type: 'session/tasks', sessionId: 's', tasks: [] }, + { type: 'session/tasks', sessionId: 's', tasks: [ + { id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 }, + { id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 }, + ] }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) @@ -444,6 +449,14 @@ describe('events frame schemas', () => { { type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 }, + // A producer kind stays an open string, but the closed status set and + // the identity/label bounds are the carrier's own wire contract. + { type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] }, ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') }) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 42b60ce906..2f4622185c 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -74,6 +74,9 @@ { "path": "../../skill/skill" }, + { + "path": "../../tasks/tasks" + }, { "path": "../../interaction/commands" }, diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index 79cef011de..42980939cc 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -27,7 +27,7 @@ export interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // TODO(public-home): Ensure this public source repository exists before release. - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', } /** diff --git a/packages/llm/llm/tests/attribution.spec.ts b/packages/llm/llm/tests/attribution.spec.ts index db74773314..cfe84051dc 100644 --- a/packages/llm/llm/tests/attribution.spec.ts +++ b/packages/llm/llm/tests/attribution.spec.ts @@ -21,7 +21,7 @@ describe('APP_IDENTITY', () => { expect(APP_IDENTITY).toEqual({ product: 'deepseek-harness', version: manifest.version, - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', }) }) }) @@ -29,7 +29,7 @@ describe('APP_IDENTITY', () => { describe('userAgent', () => { it('renders product/version with the +url comment', () => { expect(userAgent()).toBe( - `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`, + `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness)`, ) }) diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index f3847e15ff..732ab3fa00 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1172,6 +1172,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, + { + signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', + jsDoc: '/**\n/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, every stopping transition (including the one teardown\n * performs before it awaits a slow producer), settlement, owner-disposal\n * removal, and the emptying that service disposal commits — so an observer\n * re-reads rather than accumulating deltas.\n *\n * Delivery is owner-relative on the same terms as {@link onTaskDone}: an\n * observer registered from an unscoped context — a host composition\'s own\n * carrier — sees every owner, while one registered under an agent\n * composition\'s scope sees exactly the agents composed under it.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', + }, { signature: 'abstract attachSurface(name: string): () => void', jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', @@ -3213,6 +3217,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskRead', declaration: 'export interface TaskRead {\n text: string;\n snapshot: TaskSnapshot;\n}', }, + { + name: 'TasksChangedListener', + declaration: 'export type TasksChangedListener = (owner: Agent | undefined) => void;', + }, { name: 'TaskSnapshot', declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', diff --git a/packages/skill/skill-badge/assets/dsh-badge.md b/packages/skill/skill-badge/assets/dsh-badge.md index 5a789fac2f..9f15ed4d54 100644 --- a/packages/skill/skill-badge/assets/dsh-badge.md +++ b/packages/skill/skill-badge/assets/dsh-badge.md @@ -6,14 +6,14 @@ Add the official “powered by dsh” badge without recreating or restyling it. - Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white` -- Project URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` +- Project URL: `https://github.com/deepseek-ai/deepseek-harness` ## Markdown Use this linked badge in Markdown: ```markdown -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) ``` If attribution should not be linked, use: diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 7978944edb..6366024f74 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -15,7 +15,10 @@ import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' @@ -60,9 +63,10 @@ function isTerminal(status: TaskStatus): boolean { class TaskLayer implements ScopeLayer { readonly surfaces = new AnonymousEntries() readonly listeners = new AnonymousEntries() + readonly changed = new AnonymousEntries() isEmpty(): boolean { - return this.surfaces.isEmpty() && this.listeners.isEmpty() + return this.surfaces.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() } } @@ -147,6 +151,9 @@ export class LocalTaskService extends TaskService { this.settle(task, { status: 'failed', detail: String(error) }) }, ) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(task.owner) return id } @@ -184,6 +191,7 @@ export class LocalTaskService extends TaskService { task.cancel(reason) task.status = 'stopping' task.reported = true + this.notifyChanged(task.owner) return 'requested' } @@ -245,6 +253,14 @@ export class LocalTaskService extends TaskService { ) } + onTasksChanged(listener: TasksChangedListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.changed.append(listener), + { label: 'tasks.onTasksChanged()' }, + ) + } + attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -318,6 +334,35 @@ export class LocalTaskService extends TaskService { } } + /** + * The change observers that own `owner`'s updates, resolved exactly like + * {@link listenersFor}: the global layer — a host composition's own carrier, + * which serves every owner — then each scoped layer along the owner's chain. + * An observer outside that chain belongs to another composition and would + * otherwise be told about agents it does not compose. + * @param owner - the owner whose visible set moved, or undefined for unowned work. + * @returns the observers to notify, in registration order per layer. + */ + private *changedFor(owner?: Agent): IterableIterator { + yield* this.layers.global.changed.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + } + + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changedFor(owner)) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`tasks: onTasksChanged listener threw: ${String(error)}`) + } + } + } + /** * Record the first terminal outcome, notify contained listeners, and release * waiters. First-wins preserves a teardown force-failure against late producer @@ -347,6 +392,7 @@ export class LocalTaskService extends TaskService { task.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() task.markSettled() + this.notifyChanged(task.owner) } /** @@ -379,6 +425,9 @@ export class LocalTaskService extends TaskService { this.cancelForTeardown(owned, 'owner disposed') await Promise.all(owned.map(task => task.settled)) for (const task of owned) this.store.delete(task.id) + // Removal is the one visible-set change no per-task record carries, so it + // must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) } /** @@ -392,7 +441,14 @@ export class LocalTaskService extends TaskService { const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) + // Distinct owners whose records just disappeared. A change observer files + // into the layer of the context that registered it, so a consumer mounted + // outside this service — the api-proxy carrier registers from the mux + // stream — is still reachable here. Without this it keeps the rows it last + // received after a registry reload. + const emptied = new Set(all.map(task => task.owner)) this.store.clear() + for (const owner of emptied) this.notifyChanged(owner) // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() @@ -410,6 +466,10 @@ export class LocalTaskService extends TaskService { try { task.cancel(reason) task.status = 'stopping' + // Teardown reaches settlement only after the producer releases, which a + // slow stop can defer; announcing the transition here is what keeps an + // observer from showing `running` for that whole window. + this.notifyChanged(task.owner) } catch (error: unknown) { const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index b64f2142aa..b4f0ced798 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -847,3 +847,150 @@ describe('LocalTaskService disposal', () => { expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) }) + +describe('LocalTaskService.onTasksChanged', () => { + it('fires after registration, the stopping transition, and settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + // Registration is announced only once the record is readable. + expect(seen).toEqual(['alice']) + expect(ctx.tasks.list(owner)).toHaveLength(1) + + expect(ctx.tasks.kill(id, owner)).toBe('requested') + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('stopping') + + p.settle({ status: 'killed' }) + await tick() + expect(seen).toEqual(['alice', 'alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('killed') + await disposeAgentScope(owner) + }) + + it('reports an unowned change as undefined, since every caller can see it', async () => { + const ctx = await harness() + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([undefined]) + }) + + it('announces the owner-disposal removal, and stays silent when that owner had none', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + const bystander = stubAgent(ctx, 'bob') + ctx.agents.register(owner) + ctx.agents.register(bystander) + const p = producer({ owner }) + ctx.tasks.start(p.spec) + + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + p.settle({ status: 'completed' }) + await tick() + expect(seen).toEqual(['alice']) + + // Disposing an owner with no records changes no visible set. + await disposeAgentScope(bystander) + expect(seen).toEqual(['alice']) + + await disposeAgentScope(owner) + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.list(owner)).toEqual([]) + }) + + it('contains a throwing listener so the lifecycle commit still stands', async () => { + const ctx = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') }) + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const id = ctx.tasks.start(producer().spec) + expect(id).toBe('bash-1') + expect(seen).toEqual([undefined]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw')) + }) + + it('unregisters through its disposer and with its fiber (HMR safety)', async () => { + const ctx = await harness() + const seen: number[] = [] + const detach = ctx.tasks.onTasksChanged(() => void seen.push(1)) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.tasks.onTasksChanged(() => void seen.push(2)) + }, { inject: ['tasks'] })) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2]) + + detach() + detach() // second call of the same disposer is a no-op + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + + await fiber.dispose() + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + }) +}) + +describe('LocalTaskService teardown change notifications', () => { + it('announces the stopping transition during owner teardown, before settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + + const statuses: (string | undefined)[] = [] + ctx.tasks.onTasksChanged((changed) => { + statuses.push(changed === undefined ? undefined : ctx.tasks.list(changed)[0]?.status) + }) + + // A slow producer keeps teardown parked between cancel and settlement; + // an observer must not be left showing `running` for that whole window. + const disposal = disposeAgentScope(owner) + await tick() + expect(statuses).toEqual(['stopping']) + + p.settle({ status: 'killed' }) + await disposal + // Settlement, then the removal that empties the visible set. + expect(statuses).toEqual(['stopping', 'killed', undefined]) + expect(ctx.tasks.list(owner)).toEqual([]) + void id + }) + + it('announces the emptied set to a listener registered outside this service (reload safety)', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('test-surface') + + // The api-proxy carrier registers from its own stream context, not the + // registry's fiber, so it is still listening when the registry unloads. + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + let settle!: (outcome: TaskOutcome) => void + ctx.tasks.start({ + kind: 'bash', + label: 'sleep 600', + run: () => ({ + cancel() { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + seen.length = 0 + + await fiber.dispose() + // stopping (teardown cancel), settlement, then the final empty set. + expect(seen).toEqual([undefined, undefined, undefined]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 95bb5a3889..7c7ceba672 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: f23a93e3cb1fc5aad5bad053f832bb66baa8eb64 -README.zh.md: c3ea9125c4db9f2d86722cbf490b4f0eecfbe25a +README.md: 053e407d2e28cb175ebe9de15c7e16ef04e95cb8 +README.zh.md: dd39b1098cd01d7db9db6e49210c2b1149b37ecb diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index f23a93e3cb..053e407d2e 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,9 +12,10 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. +- `onTasksChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying. - `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner. -Both registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. +All three registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index c3ea9125c4..dd39b1098c 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,9 +12,10 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 -这两类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 +这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index cfc7fa70bf..8c968b743b 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -22,12 +22,17 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", diff --git a/packages/tasks/tasks/src/brand.ts b/packages/tasks/tasks/src/brand.ts new file mode 100644 index 0000000000..b9596514b3 --- /dev/null +++ b/packages/tasks/tasks/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-tasks' owned branded id, carried across the registry, the model-facing + * control surface, and the client wire. + * + * It lives in its own leaf because the package root and `./types` both reach + * `dsh-agent` through the owner and listener signatures, which a Client program + * cannot resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-tasks/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies a background task. The registry generates `-N`; predictable + * ids rely on owner authorization rather than secrecy. + */ +export type TaskId = Branded<'TaskId'> + +/** + * Brand a string as a {@link TaskId}. + * @param id - the raw task-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function TaskId(id: string): TaskId { + return id as TaskId +} diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 126ba075ff..06bb2e38fb 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -8,7 +8,9 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts' +import type { + TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from './types.ts' export { TaskId } from './types.ts' export type { @@ -21,6 +23,7 @@ export type { TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, } from './types.ts' declare module '@deepseek-ai/cordis' { @@ -134,6 +137,30 @@ export abstract class TaskService extends Service { */ abstract onTaskDone(listener: TaskDoneListener): () => void + /** + /** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index d722f3fce6..a05b75817c 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -4,24 +4,11 @@ * @module @deepseek-ai/dsh-tasks/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' +import type { TaskId } from './brand.ts' -/** - * Identifies a background task. The registry generates `-N`; predictable - * ids rely on owner authorization rather than secrecy. - */ -export type TaskId = Branded<'TaskId'> - -/** - * Brand a string as a {@link TaskId}. - * @param id - the raw task-id string (the registry generates `-N`). - * @returns the same string, branded; no validation is performed. - */ -export function TaskId(id: string): TaskId { - return id as TaskId -} +export { TaskId } from './brand.ts' /** * Task lifecycle: `running`, optionally `stopping`, then exactly one terminal @@ -157,3 +144,14 @@ export type TaskDoneListener = ( snapshot: TaskSnapshot, owner: Agent | undefined, ) => void | PromiseLike + +/** + * Observation callback for a change to what one owner's {@link TaskService.list} + * would return. It is owner-granular rather than task-granular because the + * change may be a removal, which no per-task record can express, and because + * its consumers re-read the whole visible set anyway. + * + * An `undefined` owner means an unowned task changed, so every caller's visible + * set changed with it. + */ +export type TasksChangedListener = (owner: Agent | undefined) => void diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index 73b0a3fe25..c9a3316d94 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** * Minimal concrete registry: one canned record. The Service Definition owns the contract @@ -50,6 +52,10 @@ class StubTaskService extends TaskService { return () => {} } + onTasksChanged(_listener: TasksChangedListener): () => void { + return () => {} + } + attachSurface(_name: string): () => void { return () => {} } @@ -70,6 +76,8 @@ describe('TaskService seam', () => { await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id }) const detachListener = ctx.tasks.onTaskDone(() => {}) detachListener() + const detachChanges = ctx.tasks.onTasksChanged(() => {}) + detachChanges() detachSurface() }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46d392bc4d..a787f4c68e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1566,6 +1566,9 @@ importers: '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-task': + specifier: workspace:^ + version: link:../../client/ui-task '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -2715,6 +2718,40 @@ importers: specifier: ~18.3.1 version: 18.3.31 + packages/client/ui-task: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + packages/client/ui-theme: dependencies: '@deepseek-ai/dsh-client-connection': @@ -4434,6 +4471,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 1941e66acb..a32117bab8 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -404,6 +404,7 @@ export const LINK_MAP: Readonly> = { TaskRead: 'tasks.md', TaskSnapshot: 'tasks.md', TaskStart: 'tasks.md', + TasksChangedListener: 'tasks.md', TokenMeasurement: 'token-meter.md', CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', diff --git a/scripts/project-doc-site.spec.ts b/scripts/project-doc-site.spec.ts index a8f339cec7..9f29abda4c 100644 --- a/scripts/project-doc-site.spec.ts +++ b/scripts/project-doc-site.spec.ts @@ -104,7 +104,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 55c7204d78..e7acc73998 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -209,7 +209,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 1ffc0384f3..88b18e7bb0 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-task': { kind: 'none', reason: 'Browser-side read-only projection of ctx.tasks records; dsh-tool-tasks owns the model-facing surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index 372018d7cf..4ff7d5bc62 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -1,60 +1,45 @@ import { describe, expect, it } from 'vitest' -import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' +import { findUnavailableRepositoryReferences } from './verify-public-repository-links.ts' -describe('public repository link policy', () => { - it('rejects encoded and case-varied internal identities without blocking public repositories', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') - const htmlEncodedRepository = internalRepository.replace('/', '/') - const jsonEscapedRepository = internalRepository.replace('/', '\\/') - const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) +describe('repository link policy', () => { + it('rejects encoded and case-varied references to the unavailable repository', () => { + const unavailableOwner = ['deepseek', 'ai'].join('-') + const unavailableName = ['deepseek', 'harness', 'sdk'].join('-') + const unavailableRepository = `${unavailableOwner}/${unavailableName}` + const encodedRepository = unavailableRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = unavailableRepository.replace('/', '/') + const jsonEscapedRepository = unavailableRepository.replace('/', '\\/') + const unicodeEscapedRepository = unavailableRepository.replace('/', String.raw`\u002f`) const source = [ - 'https://github.com/deepseek-ai/deepseek-harness-sdk', - `https://github.com/${internalOwner}/cordis`, - `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + 'https://github.com/deepseek-ai/deepseek-harness', + `https://github.com/${unavailableRepository.toUpperCase()}/issues/1`, `https://github.com/${encodedRepository}/issues/2`, `https://github.com/${htmlEncodedRepository}/issues/3`, `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, - `${internalOwner.toUpperCase()}#6`, + `https://github.com/${unavailableOwner}/cordis`, + `https://github.com/example/${unavailableName}`, ].join('\n') - expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + expect(findUnavailableRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 2 }, { file: 'subject.md', line: 3 }, { file: 'subject.md', line: 4 }, { file: 'subject.md', line: 5 }, { file: 'subject.md', line: 6 }, - { file: 'subject.md', line: 7 }, - { file: 'subject.md', line: 8 }, ]) }) - it('allows only the exact audited trusted-publishing repository declarations', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const repositoryUrl = `git+https://github.com/${internalRepository}.git` - const manifestLine = ` "url": "${repositoryUrl}",` - const constraintLine = `const repositoryUrl = '${repositoryUrl}'` - const allowedDeclarations = [ - ['native/landlock-run/packages/entry/package.json', manifestLine], - ['native/landlock-run/packages/linux-arm64/package.json', manifestLine], - ['native/landlock-run/packages/linux-x64/package.json', manifestLine], - ['scripts/check-workspace-constraints.ts', constraintLine], - ] as const + it('preserves frozen archived Agent Notes', () => { + const unavailableRepository = ['deepseek-ai', 'deepseek-harness-sdk'].join('/') - for (const [file, source] of allowedDeclarations) { - expect(findInternalRepositoryReferences(file, source)).toEqual([]) - } - - const wrongFile = 'native/landlock-run/package.json' - expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }]) - - const manifestFile = 'native/landlock-run/packages/entry/package.json' - const wrongField = ` "homepage": "${repositoryUrl}",` - expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }]) - - const encodedLine = manifestLine.replace('github.com/', 'github.com\\/') - expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/archived/process/historical-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/implemented/process/active-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([{ file: '.agents/notes/implemented/process/active-record.md', line: 1 }]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index e612e6641f..a336b725fc 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -1,4 +1,4 @@ -/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */ +/** Reject tracked files that reference an unavailable legacy repository. */ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' @@ -6,22 +6,13 @@ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' const root = resolve(import.meta.dirname, '..') -const internalOwner = ['deepseek', 'harness'].join('-') -const internalRepository = [internalOwner, internalOwner].join('/') -const internalIssueShorthand = `${internalOwner}#` -const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git` - -/** Exact declarations that intentionally expose the source repository for trusted publishing. */ -const allowedInternalRepositoryLineByFile: Readonly> = { - 'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`, -} +const unavailableOwner = ['deepseek', 'ai'].join('-') +const unavailableRepositoryName = ['deepseek', 'harness', 'sdk'].join('-') +const unavailableRepository = `${unavailableOwner}/${unavailableRepositoryName}` +const archivedAgentNotePrefix = '.agents/notes/archived/' const namedReferenceCharacters: Readonly> = { hyphen: '-', - num: '#', sol: '/', } @@ -40,8 +31,8 @@ function canonicalReferenceText(source: string): string { .toLowerCase() } -/** One tracked reference to the internal repository. */ -export interface InternalRepositoryReference { +/** One tracked reference to the unavailable repository. */ +export interface UnavailableRepositoryReference { /** Repository-relative file path. */ file: string /** One-based source line. */ @@ -49,20 +40,18 @@ export interface InternalRepositoryReference { } /** - * Locate unaudited internal-repository references in one text file. + * Locate unavailable-repository references in one active text file. * @param file - Repository-relative path used in diagnostics. * @param source - Text to inspect. - * @returns every matching source line. + * @returns every matching source line, excluding frozen archived Agent Notes. */ -export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +export function findUnavailableRepositoryReferences(file: string, source: string): UnavailableRepositoryReference[] { + if (file.startsWith(archivedAgentNotePrefix)) return [] + + const references: UnavailableRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { const canonicalLine = canonicalReferenceText(line) - const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file] - if (!isAllowedPublishingDeclaration - && (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) { - references.push({ file, line: index + 1 }) - } + if (canonicalLine.includes(unavailableRepository)) references.push({ file, line: index + 1 }) } return references } @@ -73,8 +62,8 @@ function trackedFiles(repoRoot: string): string[] { .filter(file => file !== '') } -function scanRepository(repoRoot: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +function scanRepository(repoRoot: string): UnavailableRepositoryReference[] { + const references: UnavailableRepositoryReference[] = [] for (const file of trackedFiles(repoRoot)) { const path = resolve(repoRoot, file) if (!existsSync(path)) continue @@ -82,7 +71,7 @@ function scanRepository(repoRoot: string): InternalRepositoryReference[] { if (!stat.isFile() && !stat.isSymbolicLink()) continue const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') if (source.includes('\0')) continue - references.push(...findInternalRepositoryReferences(file, source)) + references.push(...findUnavailableRepositoryReferences(file, source)) } return references } @@ -92,9 +81,9 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const references = scanRepository(root) if (references.length === 0) { - console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.') + console.log('verify-public-repository-links: tracked files reference no unavailable repository.') } else { - console.error('verify-public-repository-links: unexpected internal repository references found:') + console.error('verify-public-repository-links: unavailable repository references found:') for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) process.exitCode = 1 } diff --git a/tsconfig.base.json b/tsconfig.base.json index fcdea094f3..a08b438f3a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], + "@deepseek-ai/dsh-tasks/brand": ["./packages/tasks/tasks/src/brand.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], @@ -178,6 +179,7 @@ "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-task": ["./packages/client/ui-task/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..fce7d45b7b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -66,6 +66,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-task" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-agent-preset" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 9473e8b923..2beed0b1c3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -61,6 +61,7 @@ "apps/web/tests/subagent-interrupt.e2e.ts", "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", + "apps/web/tests/background-task-list.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 4dc1a5774b..4cef3f0a67 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: 'Edit this page on GitHub', },